Skip to content

CL-6494: fix sign-in rate limiting (raise the limit, key it on the account) - #229

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-6494-auth-rate-limit
Aug 21, 2026
Merged

TheGreatAxios merged 3 commits into
mainfrom
cl-6494-auth-rate-limit

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

better-auth's built-in special rule for /sign-in* is a tight 3 attempts / 10 seconds, keyed on client IP. When it can't resolve a trustworthy IP it falls back to one shared bucket per path for every visitor — in production that's outage-shaped (one mistyped password locks out everyone else signing in, including, in practice, the owner), and locally it locks out the sole developer.

This PR is two independent, separately-reviewable changes:

  1. Raise the limit + human-readable 429s (safe regardless of how IP resolution lands).
  2. Redesign sign-in's brute-force defense to key on the account, not client IP — a peer review of an earlier version of this PR correctly blocked it on the IP-trust half; this replaces that approach rather than patching it.

Commit 1 — raise the sign-in rate limit and give it a human-readable message

  • Added a signInRateLimit config (SIGNIN_RATE_LIMIT_WINDOW_SECONDS / SIGNIN_RATE_LIMIT_MAX, defaulting to 60s / 10) overriding better-auth's built-in 10s/3 special rule, applied the same in every environment — not branched on NODE_ENV — consistent with this file's existing rejection of inferring auth behavior from NODE_ENV.
  • apps/web/src/session.ts's shared postAuth helper (signIn/signUp) and signInSocial now read better-auth's X-Retry-After header and surface "Too many sign-in attempts. Try again in N seconds." instead of the bare "Too many requests" body.

This relieves the lockout on its own, regardless of client-IP resolution, since it just widens whatever bucket better-auth ends up using.

Commit 2 — key sign-in rate limiting on the target account, not client IP

An earlier version of this change added advanced.ipAddress.ipAddressHeaders: ["x-forwarded-for"] to resolve a client IP for the limiter. Peer review blocked it on two grounds, both confirmed independently against better-auth's source and Railway's docs:

  1. Wrong header. Railway's docs (docs.railway.com/networking/public-networking/specs-and-limits) list X-Real-IP as the header its edge sets for the client's address — not x-forwarded-for, which the removed comment asserted without a source.
  2. The serious one. better-auth's getIPFromHeader trusts a single-value IP header verbatim whenever no trustedProxies is configured (confirmed by reading @better-auth/core's ip.ts). Railway's private networking lets any same-project service — this deployment's agent-driven sidecars included — reach the hub directly at <service>.railway.internal, bypassing the edge entirely. A caller on that network can send a fresh forged IP header on every request and get an independent rate-limit bucket each time: a complete bypass of sign-in brute-force protection from inside our own trust boundary. Swapping in X-Real-IP would only fix finding 1 while leaving finding 2 wide open, since that header is exactly as forgeable over the internal network.

better-auth has no extension point that reaches this: customRules can only override a matched path's window/max (or opt it out via false) — it never sees or changes the rate-limit key — and customStorage only ever receives the already-computed ip|path key. Neither can see the request body, so neither can key on anything else.

Fix: sign-in enforcement now lives entirely in a small dedicated limiter (apps/hub/src/sign-in-rate-limit.ts) keyed on the normalized target email, with customRules["/sign-in/email"] set to false to fully disable better-auth's own IP-keyed rule for this path rather than run a second, weaker mechanism beside it.

  • An attacker rotating IP headers still cannot exceed the budget for the one account they're actually attacking — the threat brute-force limiting exists to stop.
  • Client IP is deliberately not composed into the key: this deployment can't tell an edge-forwarded request from a forged one, so folding an untrustworthy IP in would only let the same forged-header trick defeat this limiter too.
  • Guards against becoming a way to lock a known user out of their own account: window and count stay short/generous (60s / 10, reusing config.signInRateLimit), so a forced lockout self-heals within the window and a real user mistyping a password is unaffected.
  • X-Real-IP (Railway's actually-documented header) replaces the removed x-forwarded-for config for what it still legitimately helps with: sign-up's coarse, closed-by-default throttling, where the same private-network gap is a low-stakes, documented tradeoff rather than a brute-force bypass.
  • Upstream note: a customRules (or pre-consume) hook in better-auth that can see the parsed request body, or override the key itself, would let account-keyed limiting be expressed natively instead of living beside the built-in limiter.

Tests

  • apps/hub/src/sign-in-rate-limit.test.ts — unit tests for the limiter itself (per-account budget, independent accounts, case/whitespace-insensitive email matching, retry-seconds reporting).
  • apps/hub/test/composition.test.ts — a forged/rotating x-forwarded-for per attempt cannot exceed the per-account budget; two genuinely different accounts get independent budgets; a throttled sign-in still carries X-Retry-After and a human-readable message.
  • apps/hub/test/config.test.ts — SIGNIN_RATE_LIMIT_* env vars parse and default to 60s/10.
  • apps/web/src/session.test.ts — signIn, signUp, and signInSocial all turn a 429 into the consumer-language retry message, with and without a retry-after header.

bun run check (full monorepo typecheck/lint/test) and repo-root bun run lint both pass.

Not independently verified (flagging rather than asserting): real Railway proxy behavior end-to-end — no staging deploy exercised this, so X-Real-IP resolving correctly at Railway's edge in production is unconfirmed; this PR's core sign-in defense (the account-keyed limiter) doesn't depend on that resolving correctly, by design.

Test plan

  • Peer review of the account-keyed design and its no-IP-composition reasoning
  • Confirm on a real Railway deploy that X-Real-IP resolves as expected for sign-up
  • Merge (not done by this PR)

…ssage

better-auth's built-in special rule for /sign-in* is 3 attempts per 10
seconds. When it can't resolve a trustworthy client IP, every signed-out
visitor on that path shares one bucket, so one person mistyping a
password can lock out everyone else signing in — including, in
practice, the owner locking themselves out.

Raises the sign-in rate limit to 10 attempts per 60 seconds
(SIGNIN_RATE_LIMIT_WINDOW_SECONDS/MAX), applied the same in every
environment rather than branched on NODE_ENV, consistent with this
file's existing rejection of NODE_ENV-inferred auth behavior. This is
safe regardless of how client-IP resolution lands: it relieves the
immediate lockout on its own by giving real users, and a lone local
developer sharing an unresolved-IP bucket, more headroom to retry
before hitting the wall.

Also gives a rate-limited sign-in/sign-up/social response a
human-readable message naming what happened and when to retry,
replacing better-auth's bare "Too many requests" body.
Peer review of the sign-in rate limit correctly blocked the IP-trust
half of this change on two grounds:

1. Railway's docs (specs-and-limits) list X-Real-IP as the header its
   edge sets for the client's address — not x-forwarded-for, which the
   previous comment here asserted without a source. That claim was
   unverified and likely wrong, which would have made the client-IP
   config a no-op in production.

2. More seriously: better-auth's getIPFromHeader trusts a single-value
   IP header verbatim whenever no trustedProxies is configured, and
   Railway's private networking lets any same-project service — this
   deployment's agent-driven sidecars included — reach this hub
   directly at <service>.railway.internal, bypassing the edge
   entirely. A caller on that network can send a fresh forged IP
   header on every request and get an independent rate-limit bucket
   each time, a complete bypass of sign-in brute-force protection from
   inside our own trust boundary. Swapping in X-Real-IP would only fix
   finding 1 while leaving this wide open, since that header is
   exactly as forgeable over the internal network.

better-auth has no extension point that reaches this: `customRules`
can only override a matched path's window/max (or opt it out via
`false`) — it never sees or changes the rate-limit key — and
`customStorage` only ever receives the already-computed `ip|path` key.
Neither can key on the request body. So sign-in enforcement now lives
entirely in a small dedicated limiter (sign-in-rate-limit.ts) keyed on
the normalized target email, with `customRules["/sign-in/email"]` set
to `false` to fully disable better-auth's own IP-keyed rule for this
path rather than run a second, weaker mechanism beside it. An attacker
rotating IP headers still cannot exceed the budget for the one account
they're actually attacking, which is the threat brute-force limiting
exists to stop; client IP is deliberately not composed into the key,
since this deployment has no way to tell an edge-forwarded request
from a forged one, and folding an untrustworthy IP in would only let
the same forged-header trick defeat this limiter too. Guards against
becoming a way to lock a known user out of their own account: window
and count stay short/generous (60s / 10, config.signInRateLimit), so a
forced lockout self-heals within the window and a real user mistyping
a password is unaffected. Upstream note for better-auth: a
`customRules` (or pre-consume) hook that can see the parsed request
body, or override the key itself, would let this be expressed
natively instead of living beside the built-in limiter.

X-Real-IP replaces the removed x-forwarded-for config for what it
still legitimately helps with: sign-up's coarse, closed-by-default
throttling, where the same private-network gap is a low-stakes,
documented tradeoff rather than a brute-force bypass.
@TheGreatAxios
TheGreatAxios force-pushed the cl-6494-auth-rate-limit branch from 0da088e to ba02600 Compare August 21, 2026 15:34
@TheGreatAxios TheGreatAxios changed the title CL-6494: resolve client IP for auth rate limiting CL-6494: fix sign-in rate limiting (raise the limit, key it on the account) Aug 21, 2026
…design

config.ts's DEFAULT_SIGNIN_RATE_LIMIT_* comment still described these
knobs as raising better-auth's own IP-keyed sign-in rule. Since the
previous commit disables that rule entirely for sign-in and routes
these values into the account-keyed limiter instead, the comment
described a mechanism this codebase no longer uses.
@TheGreatAxios
TheGreatAxios merged commit 03ba936 into main Aug 21, 2026
2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-6494-auth-rate-limit branch August 25, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant