Better Auth rate limiting: the built-in limiter, its blind spots, and what to add
How Better Auth rate limiting works: defaults, per-path rules, storage, the auth.api blind spot and spoofable IPs, plus express-rate-limit tiers on Redis.
Better Auth has a built-in rate limiter, but it is off outside production, it
defaults to in-memory storage that does not survive a restart or span replicas,
it trusts a spoofable X-Forwarded-For header unless you configure the IP source,
and it never runs for calls you make through auth.api on the server. If your
Express app wraps Better Auth in its own routes, as most do, you need a second
limiter in front. This guide explains both layers, checked against
[email protected] (the version useDeploy ships) and [email protected],
and shows how useDeploy stacks them.
What Better Auth limits by default
The rate limit docs and the
1.6.9 source (context/create-context.mjs, api/rate-limiter/index.mjs) agree:
- Enabled only in production.
rateLimit.enableddefaults toNODE_ENV === 'production'. In development you will never see a 429 unless you setenabled: true. - Global rule: 100 requests per 10 seconds, per client IP and path.
- Stricter built-in rules:
/sign-in/*,/sign-up/*,/change-passwordand/change-emailallow 3 requests per 10 seconds. Password-reset and verification-email paths allow 3 per 60 seconds. - Plugin rules: the two-factor plugin limits
/two-factor/*to 3 per 10 seconds, and the magic link plugin limits its sign-in and verify paths to 5 per 60 seconds (rateLimit: { window, max }on the plugin changes that). - Response: HTTP 429 with an
X-Retry-Afterheader.
You tune it in betterAuth({ rateLimit: { ... } }):
export const auth = betterAuth({
rateLimit: {
enabled: true, // also in development
window: 10, // seconds
max: 100,
storage: 'memory', // or 'database' | 'secondary-storage' (needs secondaryStorage)
customRules: {
'/sign-in/email': { window: 10, max: 3 },
},
},
});customRules also accepts a function, or false to turn a path off. Storage is
memory unless you configured secondaryStorage (then it uses that), or
'database', which needs a rateLimit table.
Four blind spots
1. Server-side calls are not limited
The docs say it plainly: requests made through auth.api are not rate limited. The
limiter hooks into the HTTP handler (onRequestRateLimit in api/index.mjs), and
auth.api.signInEmail() skips that handler. If your Express routes call
auth.api.* and return the result, which is the common way to add a custom
response shape or extra validation, Better Auth's limiter never sees those
requests.
2. The client IP is whatever the header says
By default the key is the first entry of X-Forwarded-For. A client can send that
header themselves, so each request can claim a new IP and the limit never trips.
Fix the source in advanced.ipAddress.ipAddressHeaders, or make sure your proxy
overwrites the header. advanced.ipAddress.disableIpTracking turns IP limiting off
for HTTP requests, and when no IP can be determined, rate limiting is skipped.
3. In-memory storage is per process
The memory store is a plain Map. Two replicas each keep their own counters, and
a restart clears them. That is acceptable for one instance and wrong for a
horizontally scaled API. Use secondaryStorage (Redis), or database.
4. It limits by IP and path, not by account
Spraying one account from many IPs is not slowed down. A per-email limit on the login route covers that, and Better Auth does not provide one.
The second layer: express-rate-limit tiers
express-rate-limit
sits in Express, in front of your routes, and can use Redis through
rate-limit-redis. The part that matters in production is a small set of named
tiers rather than one global limiter.
useDeploy defines four in apps/server/src/infrastructure/http/rate-limit.ts:
| Tier | Default | Window | Key | Applies to |
|---|---|---|---|---|
readLimiter | 300 | 1 minute | user id, else IP | GET, HEAD, OPTIONS under /api |
writeLimiter | 60 | 1 minute | user id, else IP | POST, PUT, PATCH, DELETE, mounted per router |
authIpLimiter | 30 | 1 minute | IP | /api/auth/* and /api/v1/auth/* |
authEmailLimiter | 5 | 15 minutes | lowercased email, else IP | sign-in, register, forgot-password, resend, magic link send, TOTP sign-in |
The numbers come from RATE_LIMIT_READ_PER_MIN, RATE_LIMIT_WRITE_PER_MIN,
RATE_LIMIT_AUTH_IP_PER_MIN and RATE_LIMIT_AUTH_EMAIL_PER_15MIN. Every limiter
answers { "code": "RATE_LIMITED", ... } with the standard RateLimit draft-7
headers.
Three implementation details are worth copying:
One store per tier, with its own prefix. Each tier gets a separate Redis store
with the prefix rl:read:, rl:write:, rl:auth-ip: or rl:auth-email:. With a
shared store the key is {prefix}{key}, so a user's reads would deplete their
write budget on the same counter.
Redis when available, memory with a loud warning otherwise.
if (!redisClient && !redisUrl) {
logger?.warn(
'Rate limit: REDIS_URL not set; using in-memory store. Multi-instance deployments WILL leak quota — set REDIS_URL.',
);
return undefined;
}RATE_LIMIT_REDIS_ENABLED=false forces the memory store even if REDIS_URL is set.
Without Redis the server still boots, which keeps local development simple.
Fail open. The limiter is built with passOnStoreError: true:
const baseLimiter = (opts) =>
rateLimit({
standardHeaders: 'draft-7',
legacyHeaders: false,
// Fail open: a Redis outage must not 500 the whole API.
passOnStoreError: true,
message: { code: 'RATE_LIMITED', message: 'Too many requests. Try again later.' },
...opts,
});The library default is fail closed (passOnStoreError: false). Which is right
depends on your threat model. For a SaaS API a Redis blip that takes down every
route is usually worse than a few minutes without limits, but know that you are
making that trade.
The limiter instances are memoized per config object, so every router that asks for
writeLimiter(deps) gets the same handler and, in memory mode, the same store.
Otherwise each router would have its own budget in development and one shared
budget in production.
Making the two layers agree on the client IP
Both limiters have to see the same address. useDeploy sets Express's trust proxy
from TRUST_PROXY, a hop count: 0 for direct connections, 1 behind a single
reverse proxy such as Traefik, Railway or Cloudflare. Per the Express
docs, req.ip is then taken
from X-Forwarded-For starting at the first untrusted hop. Get the number wrong
and two things happen. With too low a value every visitor shares the proxy's IP and
one noisy client locks out everyone. With too high a value a client can pick its
own IP.
For Better Auth, the mount in
apps/server/src/modules/iam/interfaces/http/better-auth.mount.ts overwrites the
header before handing the request over:
const normalizeClientIp: RequestHandler = (req, _res, next) => {
if (req.ip) req.headers['x-forwarded-for'] = req.ip;
else delete req.headers['x-forwarded-for'];
next();
};
router.use('/api/auth', authIpLimiter(container.cradle.rateLimit));
router.all('/api/auth/*', normalizeClientIp, toNodeHandler(container.cradle.betterAuthInstance));So the native /api/auth/* surface passes through authIpLimiter first, and
Better Auth's own limiter then sees an address Express vetted instead of a
client-supplied one.
How the layers combine in useDeploy
| Request | Better Auth limiter | App limiter |
|---|---|---|
Browser to /api/auth/* (passkeys, email links) | yes, in production | authIpLimiter |
POST /api/v1/auth/sign-in | no, it uses auth.api | authIpLimiter then authEmailLimiter |
POST /api/v1/auth/2fa/verify-sign-in | no | authIpLimiter then authEmailLimiter |
Any other POST /api/v1/* | not involved | writeLimiter |
One consequence to know about: verify-sign-in has no email in its body, so
authEmailLimiter falls back to keying by IP. That gives 5 code attempts per 15
minutes per IP by default. It is tight on purpose, because a TOTP code has only a
million values. If many users share one office IP, raise
RATE_LIMIT_AUTH_EMAIL_PER_15MIN knowing that it also loosens the other email-keyed
routes.
The same check is reachable natively at /api/auth/two-factor/verify-totp, limited to about 18 requests a minute per IP. Every path is keyed by IP; none is per account.
Better Auth's rateLimit option is not set in useDeploy's adapter, so it runs with
the defaults above (production only, memory storage). For a multi-replica
deployment you would add secondaryStorage to the adapter so those counters are
shared, but with the app limiters in front of every wrapped route the practical
exposure is the native /api/auth/* surface.
Frequently asked questions
Is Better Auth rate limiting enabled in development?
No. It defaults to on only when NODE_ENV is production. Set rateLimit.enabled: true to test it locally.
Do I need Redis for rate limiting?
For one instance, no. With two or more instances, yes, otherwise each keeps its own counters and the real limit is multiplied by the replica count.
Does the limiter protect auth.api calls from my own server?
No. Server-side calls skip it. Put an Express limiter in front of any route that calls auth.api.
What status code does a limited request get?
429 from both layers. Better Auth adds X-Retry-After; express-rate-limit adds RateLimit headers (draft-7).
Why rate limit by email as well as IP?
An attacker can rotate IPs against one account. Keying on the email caps attempts per account no matter how many addresses they use, at the cost of letting someone lock a victim out for the window.
useDeploy ships the four tiers, Redis stores with per-tier prefixes, the
trust-proxy handling and the /api/auth/* mount shown above. See the Better Auth
integration and the Redis
integration.