CL-6494: fix sign-in rate limiting (raise the limit, key it on the account) - #229
Merged
Merged
Conversation
…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
force-pushed
the
cl-6494-auth-rate-limit
branch
from
August 21, 2026 15:34
0da088e to
ba02600
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Commit 1 — raise the sign-in rate limit and give it a human-readable message
signInRateLimitconfig (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 onNODE_ENV— consistent with this file's existing rejection of inferring auth behavior fromNODE_ENV.apps/web/src/session.ts's sharedpostAuthhelper (signIn/signUp) andsignInSocialnow read better-auth'sX-Retry-Afterheader 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:docs.railway.com/networking/public-networking/specs-and-limits) listX-Real-IPas the header its edge sets for the client's address — notx-forwarded-for, which the removed comment asserted without a source.getIPFromHeadertrusts a single-value IP header verbatim whenever notrustedProxiesis configured (confirmed by reading@better-auth/core'sip.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 inX-Real-IPwould 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:
customRulescan only override a matched path'swindow/max(or opt it out viafalse) — it never sees or changes the rate-limit key — andcustomStorageonly ever receives the already-computedip|pathkey. 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, withcustomRules["/sign-in/email"]set tofalseto fully disable better-auth's own IP-keyed rule for this path rather than run a second, weaker mechanism beside it.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 removedx-forwarded-forconfig 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.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/rotatingx-forwarded-forper attempt cannot exceed the per-account budget; two genuinely different accounts get independent budgets; a throttled sign-in still carriesX-Retry-Afterand 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, andsignInSocialall 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-rootbun run lintboth pass.Not independently verified (flagging rather than asserting): real Railway proxy behavior end-to-end — no staging deploy exercised this, so
X-Real-IPresolving 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
X-Real-IPresolves as expected for sign-up