Better Auth rate limiting on serverless: the sign-in limit that doubles with every instance, measured
Better Auth rate limiting on serverless counts in memory per instance: two instances let 6 sign-ins through a limit of 3. The database fix, measured.
The kit's sign-in endpoint has brute-force protection that its own code never mentions. Better Auth switches rate limiting on by default in production and allows three sign-in attempts per ten seconds. It keeps that count in a Map inside the server process. On serverless, every instance has its own Map, so Better Auth rate limiting on serverless is per instance. I ran two instances of the kit against one database and the limit doubled. Then I moved the counter into Postgres and measured that too. It is much better, though not perfectly exact.
What Better Auth turns on for you
The kit's auth config sets no rateLimit options, so it runs on Better Auth 1.6.18's defaults. From the installed source:
rateLimit: {
...options.rateLimit,
enabled: options.rateLimit?.enabled ?? isProduction,
window: options.rateLimit?.window || 10,
max: options.rateLimit?.max || 100,
storage: options.rateLimit?.storage || (options.secondaryStorage ? "secondary-storage" : "memory"),
},
isProduction means NODE_ENV === "production". So limiting is on under next start and on your host, and off under next dev, where you will never see it. The general limit is 100 requests per 10 seconds. Tighter built-in rules apply to the paths attackers care about: /sign-in, /sign-up, /change-password and /change-email get 3 per 10 seconds, and password-reset and verification emails get 3 per 60 seconds. Each bucket is keyed by client IP plus path. The IP is the first entry of x-forwarded-for, which is the default for advanced.ipAddress.ipAddressHeaders.
Measured: one instance, then two
Setup: the kit's production build, two next start instances on different ports, one throwaway Postgres 16, and wrong-password sign-ins to /api/auth/sign-in/email. Each scenario used a fresh client IP so no window carried over.
- One instance, five attempts:
401 401 401 429 429. The 429s carriedX-Retry-After: 10. One process does exactly what the defaults say. - Two instances, the same client alternating between them: six attempts, six 401s. Each instance counted its own three. A deploy that scales to more instances gives an attacker that many more guesses per window.
- One instance, a new
x-forwarded-forvalue on the fourth attempt:401instead of429. A new header value is a new bucket, as with any IP-keyed limiter. On Vercel the platform overwrites that header, so this one is safe there. The details are in x-forwarded-for spoofing and IP rate limits.
Moving the counter into Postgres
Better Auth can keep the counts in your database instead. It needs one option and one table. In the kit that means:
// src/lib/db/schema.ts
export const rateLimit = pgTable("rateLimit", {
id: text("id").primaryKey(),
key: text("key").notNull().unique(),
count: integer("count").notNull(),
lastRequest: bigint("lastRequest", { mode: "number" }).notNull(),
});
// src/lib/auth/index.ts
export const auth = betterAuth({
database: drizzleAdapter(db(), {
provider: "pg",
schema: { user, session, account, verification, rateLimit },
}),
rateLimit: { storage: "database" },
// ...
});
npm run db:generate produces the CREATE TABLE "rateLimit" with a rateLimit_key_unique constraint. Apply it before the code that uses it ships, as covered in the Drizzle deploy-order post.
With that in place, I repeated the tests on both instances:
- Alternating between the two instances:
401 401 401 429 429 429. The limit now holds across instances. - Bursts of ten simultaneous attempts, split across both: in four bursts, exactly 3 got through twice and 4 got through twice, and the stored count matched.
The overshoot comes from how the Drizzle adapter increments on Postgres. It runs UPDATE … WHERE id IN (SELECT id … WHERE count < max LIMIT 1), so the cap is checked inside the sub-select rather than on the row being updated. That most likely lets a concurrent increment slip past it. One extra attempt in a burst of ten is a long way from "a full limit per instance", but it is not exact.
Things that bite
- It is off in development. Test rate limits with
next start, notnext dev, or you will conclude there are none. - No IP means one shared bucket. If no trusted IP header is present, Better Auth logs a warning and falls back to a single
no-trusted-ipbucket per path. Every user then shares three sign-in attempts per ten seconds, and anyone can lock everyone else out. Next.js's own server fillsx-forwarded-forfrom the socket when it is missing, but other runtimes may not. - Database storage costs a write per request. In the source, the limiter runs on every request through Better Auth's router, not only sign-in, so with database storage the client's session checks write to the table too. The adapter prunes expired rows in the background, but measure the extra load before turning it on.
- This is not your app's limiter. Better Auth only guards its own routes. The kit's chat route has its own in-memory limiter, with the same per-instance problem. A Postgres version is in a Postgres rate limiter for serverless Next.js.
Shipwright, the Next.js and Claude starter kit this blog documents, runs Better Auth on these defaults today. Sharing the counter across instances is the one option and one table shown above.