Skip to content

Commit 5d4bda2

Browse files
zachdunnclaude
andauthored
Move auth rate limiting from a Durable Object to Rate Limiting bindings (#1295)
Better Auth's rules map onto four per-colo ratelimit bindings (3/10s, 5/60s, 30/60s, 100/10s), never stricter than the rule they serve. The binding counts in-colo, so a check can't time out the way the DO round trip did. The RateLimitCounter class is deleted with a v2 migration. Claude-Session: https://claude.ai/code/session_01DXrMuWVjjG6oMFi4aktmNw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 050cbb8 commit 5d4bda2

10 files changed

Lines changed: 191 additions & 467 deletions

File tree

‎apps/auth/src/auth.test.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe("upsertGithubLogin", () => {
5252
});
5353

5454
// Rate-limit storage selection (2026-08-23 incident follow-up). The point of
55-
// these two: production must get the Durable-Object customStorage, and every
55+
// these two: production must get the binding-backed customStorage, and every
5656
// binding-less caller (tests, bare local envs) must still build without one.
5757
describe("rateLimit storage selection", () => {
5858
function env(overrides: Partial<AuthEnv> = {}): AuthEnv {
@@ -66,12 +66,11 @@ describe("rateLimit storage selection", () => {
6666
};
6767
}
6868

69-
it("uses the Durable-Object customStorage when RATE_LIMIT is bound", async () => {
70-
const RATE_LIMIT = {
71-
idFromName: (name: string) => name,
72-
get: () => ({ consume: async () => ({ allowed: true, retryAfter: null }) }),
73-
};
74-
const auth = await createAuth(env({ RATE_LIMIT }));
69+
const allow = { limit: async () => ({ success: true }) };
70+
const limiters = { RL_SIGN_IN: allow, RL_EMAIL: allow, RL_OAUTH: allow, RL_DEFAULT: allow };
71+
72+
it("uses the binding-backed customStorage when the rate limiters are bound", async () => {
73+
const auth = await createAuth(env(limiters));
7574
const rateLimit = auth?.options.rateLimit;
7675
expect(rateLimit?.enabled).toBe(true);
7776
expect(typeof rateLimit?.customStorage?.consume).toBe("function");
@@ -89,17 +88,18 @@ describe("rateLimit storage selection", () => {
8988
// End-to-end through Better Auth's own limiter, using a customStorage that
9089
// denies EVERYTHING. Anything that still answers non-429 is genuinely exempt.
9190
describe("rate limiting exemptions (live handler)", () => {
91+
const deny = { limit: async () => ({ success: false }) };
9292
function denyAllEnv(): AuthEnv {
9393
return {
9494
DB: createFakeD1(),
9595
BETTER_AUTH_SECRET: "x".repeat(32),
9696
BETTER_AUTH_URL: "https://uploads.sh",
9797
WEB_ORIGIN: "https://uploads.sh",
9898
ENVIRONMENT: "production",
99-
RATE_LIMIT: {
100-
idFromName: (name: string) => name,
101-
get: () => ({ consume: async () => ({ allowed: false, retryAfter: 30 }) }),
102-
},
99+
RL_SIGN_IN: deny,
100+
RL_EMAIL: deny,
101+
RL_OAUTH: deny,
102+
RL_DEFAULT: deny,
103103
};
104104
}
105105

@@ -138,7 +138,7 @@ describe("rate limiting exemptions (live handler)", () => {
138138
}),
139139
);
140140
expect(res.status).toBe(429);
141-
expect(res.headers.get("X-Retry-After")).toBe("30");
141+
expect(res.headers.get("X-Retry-After")).toBe("60");
142142
});
143143
});
144144

‎apps/auth/src/auth.ts‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ import { hasProductUpdatesOptOut } from "./product-updates";
5858
import { reportHandledError } from "@uploads/observability";
5959
import { setIssueContext } from "@uploads/observability/issues";
6060
import { buildTokenGrantAfterHandler, captureRefreshTokenPriorState } from "./oauth-observability";
61-
import { createDurableRateLimitStorage, type RateLimitNamespaceLike } from "./rate-limit";
61+
import {
62+
createBindingRateLimitStorage,
63+
rateLimitTiers,
64+
type RateLimitBindings,
65+
} from "./rate-limit";
6266
import * as schema from "./schema";
6367
import { stripePluginOrNone } from "./stripe-plugin";
6468
import { authTrustedOrigins, isTrustedOrigin } from "./trusted-origins";
@@ -523,7 +527,8 @@ async function enforceLastAdminGuard(
523527
}
524528

525529
export type AuthEnv = GitHubCredentialsEnv &
526-
DashApiKeyEnv & {
530+
DashApiKeyEnv &
531+
RateLimitBindings & {
527532
DB: D1Database;
528533
EMAIL?: import("./email").EmailBinding;
529534
BETTER_AUTH_URL?: string;
@@ -536,11 +541,6 @@ export type AuthEnv = GitHubCredentialsEnv &
536541
ENVIRONMENT?: string;
537542
BETTER_AUTH_TRUSTED_ORIGINS?: string;
538543
AUTH_RATE_LIMIT_DISABLED?: string;
539-
/**
540-
* Durable Object namespace backing the rate limiter (src/rate-limit-do.ts).
541-
* Without it, Better Auth's in-memory storage is used.
542-
*/
543-
RATE_LIMIT?: RateLimitNamespaceLike;
544544
/** Ephemeral flag passed only by `pnpm dev:stack`; never configure in prod. */
545545
LOCAL_STACK?: string;
546546
/** Gates stripePluginOrNone and feeds the memoization key. */
@@ -557,15 +557,16 @@ export type AuthEnv = GitHubCredentialsEnv &
557557
export type BetterAuthInstance = ReturnType<typeof buildAuth>;
558558

559559
/**
560-
* Rate-limit storage: the Durable Object when RATE_LIMIT is bound, Better
560+
* Rate-limit storage: the Rate Limiting bindings when all are bound, Better
561561
* Auth's in-memory Map otherwise. Both branches return the same shape so
562562
* callers don't have to narrow a union.
563563
*/
564564
function rateLimitStorage(env: AuthEnv): {
565-
customStorage?: ReturnType<typeof createDurableRateLimitStorage>;
565+
customStorage?: ReturnType<typeof createBindingRateLimitStorage>;
566566
storage?: "memory";
567567
} {
568-
if (env.RATE_LIMIT) return { customStorage: createDurableRateLimitStorage(env.RATE_LIMIT) };
568+
const tiers = rateLimitTiers(env);
569+
if (tiers) return { customStorage: createBindingRateLimitStorage(tiers) };
569570
return { storage: "memory" };
570571
}
571572

@@ -1035,13 +1036,12 @@ function buildAuth(
10351036
//
10361037
// D1 is kept out of the rate-limit path: a D1 stall once hung every
10371038
// /api/auth/* request. /get-session is exempt entirely (customRules), and
1038-
// the remaining low-frequency endpoints use one Durable Object counter
1039-
// per key (src/rate-limit-do.ts), an exact fleet-wide fixed window. The
1040-
// wrapper fails open and races the DO call against a ~1.5s timer, since
1041-
// rate limiting is abuse-shaping, not a security boundary.
1039+
// the remaining endpoints use the per-colo Rate Limiting bindings
1040+
// (src/rate-limit.ts). The wrapper fails open, since rate limiting is
1041+
// abuse-shaping, not a security boundary.
10421042
//
1043-
// Without the binding (tests, bare local envs) Better Auth's in-memory
1044-
// Map is used. KV is too coarse (1 write/sec/key) for a shared counter.
1043+
// Without the bindings (tests, bare local envs) Better Auth's in-memory
1044+
// Map is used.
10451045
rateLimit: {
10461046
enabled: isProduction && env.AUTH_RATE_LIMIT_DISABLED !== "true",
10471047
...rateLimitStorage(env),
@@ -1103,9 +1103,9 @@ function cacheKey(
11031103
githubClientSecret: github?.clientSecret ?? null,
11041104
dashApiKey,
11051105
hasEmail: Boolean(env.EMAIL),
1106-
// Presence flips the limiter between DO-backed customStorage and Better
1107-
// Auth's memory fallback, so it has to be part of the key.
1108-
hasRateLimitDO: Boolean(env.RATE_LIMIT),
1106+
// Presence flips the limiter between binding-backed customStorage and
1107+
// Better Auth's memory fallback, so it has to be part of the key.
1108+
hasRateLimiters: Boolean(rateLimitTiers(env)),
11091109
});
11101110
}
11111111

‎apps/auth/src/index.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ import * as schema from "./schema";
1717
import { billingPricesResponseBody } from "./billing-prices";
1818
import { ROBOTS_TXT } from "./robots";
1919

20-
// Durable Object backing Better Auth's rate limiter (see src/rate-limit-do.ts
21-
// and the `durable_objects` block in wrangler.jsonc). Wrangler resolves the
22-
// binding's class_name against the entrypoint's named exports, so this
23-
// re-export is load-bearing even though nothing in this file calls it.
24-
export { RateLimitCounter } from "./rate-limit-do";
25-
2620
// Credentialed CORS for the web origin (+ dev origins), scoped to /api/auth/*
2721
// only — this worker has no other public surface (D1: "CORS becomes trivial").
2822
const authCors = cors({

‎apps/auth/src/rate-limit-do.ts‎

Lines changed: 0 additions & 66 deletions
This file was deleted.

0 commit comments

Comments
 (0)