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.

MC
Martin Coll

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.enabled defaults to NODE_ENV === 'production'. In development you will never see a 429 unless you set enabled: true.
  • Global rule: 100 requests per 10 seconds, per client IP and path.
  • Stricter built-in rules: /sign-in/*, /sign-up/*, /change-password and /change-email allow 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-After header.

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:

TierDefaultWindowKeyApplies to
readLimiter3001 minuteuser id, else IPGET, HEAD, OPTIONS under /api
writeLimiter601 minuteuser id, else IPPOST, PUT, PATCH, DELETE, mounted per router
authIpLimiter301 minuteIP/api/auth/* and /api/v1/auth/*
authEmailLimiter515 minuteslowercased email, else IPsign-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

RequestBetter Auth limiterApp limiter
Browser to /api/auth/* (passkeys, email links)yes, in productionauthIpLimiter
POST /api/v1/auth/sign-inno, it uses auth.apiauthIpLimiter then authEmailLimiter
POST /api/v1/auth/2fa/verify-sign-innoauthIpLimiter then authEmailLimiter
Any other POST /api/v1/*not involvedwriteLimiter

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.