Skip to content

fix(auth): limit password and code guessing through the /auth/v1 proxy - #229

Open
JOY (JOY) wants to merge 1 commit into
mainfrom
feat/auth-proxy-client-limit
Open

JOY (JOY) wants to merge 1 commit into
mainfrom
feat/auth-proxy-client-limit

Conversation

@JOY

@JOY JOY (JOY) commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #221

Problem

Supabase Auth (v2.197.0, as pinned) limits requests per client address only, keyed on the X-Real-IP the proxy sets. /token allows a burst of 30, then 150 per 5 minutes. /verify allows 30 per 5 minutes. MFA challenge and verify allow 15 a minute. So one address could try about 1,800 passwords an hour, and many addresses could try one account, or one 6-digit emailed or TOTP code, without any limit. #190 (#217) covers only the endpoints that send email. #213's per-user limit applies only once a token exists.

Decision per path (recorded in plan §4.11)

Path Crove-side limit Key
POST /token, password grant yes email or phone in the body
POST /verify naming an email or phone (6-digit code) yes that email or phone
POST /factors/<id>/verify (TOTP code) yes the factor
/token with refresh_token, pkce, id_token no: long random secrets; Supabase Auth's per-address limit bounds refresh churn
POST /verify with a link's token hash only no: too long to guess
otp already covered by the #190 email budget
user no: every call needs the user's access token; an email change is covered by #190; the reauthentication nonce is off in the bundled config

Every /token grant except the three safe ones counts as a password grant. The grant is read from the query and also from the body parsed as a form, whatever the Content-Type, because Go's FormValue prefers a form body's grant_type. Without that, a request saying ?grant_type=refresh_token could carry a password guess past the count. A multipart body is never treated as safe.

Budgets

Each counted attempt takes a slot from three in-memory token buckets before it is forwarded:

  • AUTH_GUESS_LIMIT_PER_CLIENT (default 30): per client address (IPv6 per /64), all accounts together, in any 15 minutes.
  • AUTH_GUESS_LIMIT_PER_ACCOUNT_CLIENT (default 10): per account from one address in any 15 minutes.
  • AUTH_GUESS_LIMIT_PER_ACCOUNT (default 50): per account from every address together in an hour.

An attempt Supabase Auth accepts (2xx), or refuses with its own 429 (so the secret was never checked), gives its slots back. In effect only failed attempts count. A refusal keeps the slots, and so does no answer, since the secret may have been checked. Over a budget the proxy forwards nothing and answers 429 with code: RATE_LIMITED, Retry-After and error_code: over_request_rate_limit. That error_code is Supabase Auth's own, which auth-js reads, and the SPA now shows a message for it in vi and en.

Lockout. The kernel refuses to start when the per-account budget is less than an hour of what one address may try on one account (4 × AUTH_GUESS_LIMIT_PER_ACCOUNT_CLIENT). One address alone therefore never drains an account's budget. A test runs six hours of full-rate attempts from one address and checks that the owner always gets through. Locking someone out takes several addresses, and even then they can sign in with an emailed link (recovery or magic link). Links and PKCE code exchanges are not counted.

Why in memory. Plan §4.4 step 0 asks for limits before any DB write, so unauthenticated traffic should not cost a database write per attempt. As with #213's per-user /api limit, the budgets hold per API instance: N instances allow N times each budget, as documented. Account keys are SHA-256 hashes, so a 1 MB email value cannot bloat memory. Each budget keeps at most 50,000 buckets. A full map first drops buckets that have refilled, then the bucket touched longest ago. It is swept at most once a second, so a flood of new keys cannot force a full sweep on every request.

Residual risks (plan §4.11): an attacker with many addresses (a botnet, many IPv6 /64s) gets many per-address budgets and can keep one account out of password sign-in. A restart, or a flood of other accounts that evicts an account's bucket, gives that account a fresh budget.

Changes

  • apps/kernel/src/auth-guess-limit.ts (new): guessRequest decides which requests count and under which account; TokenBuckets and createGuessLimiter hold the buckets. It does not reuse feat(kernel): bound refusal audits and rate-limit requests per actor #213's RateLimiter (rate-limit.ts). This one needs slots given back, an arbitrary window, and eviction rather than refusing new keys when full: its keys come from unauthenticated clients, and refusing every new key would let a flood of addresses lock everyone out.
  • apps/kernel/src/auth-email-limit.ts: bodyAccount, fieldValues and jsonFields are exported, so both limiters read email and phone keys the same way (any case; conflicting values give no account). Behavior is unchanged and the existing tests pass.
  • apps/kernel/src/http.ts: reserves slots before forwarding, gives them back on 2xx or 429. The untrusted-proxy warning now also mentions the sign-in attempt budget.
  • apps/kernel/src/config.ts: the three settings and the lockout guard. docker-compose.yml, .env.example and README.md list them.
  • apps/app: over_request_rate_limit in AUTH_ERRORS, with vi and en messages.
  • Plan §4.4 step 0 (merged with feat(kernel): bound refusal audits and rate-limit requests per actor #213's text) and §4.11 updated.

Note for the e2e stack

The e2e stack (#91, #101, #191) is not on main yet. #191's spec makes more than 30 failed password sign-ins from one runner address to reach Supabase Auth's own limit. Once both land, e2e/compose.e2e.yml needs a higher AUTH_GUESS_LIMIT_PER_CLIENT, the same way it already raises the email budget. I'll add that to whichever PR merges second.

Tests

apps/kernel/test/auth-guess-limit.test.ts (26 tests):

  • which requests count: grants, form-body and multipart cases, a JSON body that also reads as a form, one-time codes vs link token hashes, factor id forms;
  • the buckets: refill, refund, memory bound, sweep throttle;
  • the three budgets, including that a refusal spends nothing and one address never locks an account out;
  • the configuration guard;
  • the proxy in front of a fake Supabase Auth: the 11th wrong password refused with 429 and not forwarded; accepted and 429 attempts not counted; spoofed forwarding headers ignored; code and MFA limits; refresh tokens and other paths untouched; no answer keeps the slot.

Mutation checks: reading the grant from the query only, dropping the refund on accepted attempts, and removing the sweep throttle each make a test fail.

Rebased on main at e75e6b2 (after #213, #198, #248), all on PostgreSQL 17:

Check Result
pnpm lint, pnpm typecheck, pnpm docs:check pass
pnpm test 458 passed, 12 skipped
pnpm test:db 416 passed, 8 skipped
vitest --config vitest.invariant.config.ts 28 files, 354 passed

Security review (session, while #95 is pending)

http.ts and auth*.ts are protected paths, so I reviewed the change myself:

  • grant and account parsing against Go's decoder;
  • path and factor-id normalization;
  • refusal and refund accounting: a refused reserve spends nothing, and release is idempotent;
  • concurrency: reserve is synchronous, so concurrent requests cannot overshoot;
  • memory and CPU bounds;
  • information leaks: attempts count whether or not the account exists.

The one finding was that a full map could be made to sweep on every request. It is fixed here by the once-a-second throttle, which has a test. No migration, no database change, no invariant test touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@vercel

vercel Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
crove Ready Ready Preview Oct 16, 2026 7:08am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 43 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c326c0b3-5771-4977-a7f9-ba930fa513d6
📥 Commits

Reviewing files that changed from the base of the PR and between b8ba538 and b4c5594.

📒 Files selected for processing: 12
  • .env.example
  • README.md
  • apps/app/src/i18n/messages/en.ts
  • apps/app/src/i18n/messages/vi.ts
  • apps/app/src/lib/auth.ts
  • apps/kernel/src/auth-email-limit.ts
  • apps/kernel/src/auth-guess-limit.ts
  • apps/kernel/src/config.ts
  • apps/kernel/src/http.ts
  • apps/kernel/test/auth-guess-limit.test.ts
  • docker-compose.yml
  • docs/plan/crove-os-kernel.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Supabase Auth limits requests per client address only, so one address
could try about 1,800 passwords an hour and many addresses could try one
account without limit. The proxy now counts the requests that check a
secret someone could guess before it forwards them: password sign-in
(every /token grant but refresh_token, pkce and id_token, with the grant
read from the query and from the body as a form, as Go's FormValue does),
POST /verify naming an email or phone, and POST /factors/<id>/verify.

Each takes a slot from three token buckets: the client address in any 15
minutes (AUTH_GUESS_LIMIT_PER_CLIENT, 30), the account from that address
in any 15 minutes (AUTH_GUESS_LIMIT_PER_ACCOUNT_CLIENT, 10) and the
account from every address in an hour (AUTH_GUESS_LIMIT_PER_ACCOUNT, 50).
Attempts Supabase Auth accepts, or refuses with its own 429, give their
slots back. Over a budget: 429 RATE_LIMITED with Retry-After and
error_code over_request_rate_limit, which the SPA now shows. The kernel
refuses to start when one address could drain an account's budget.

The counts live in memory per API process (no database write for
unauthenticated traffic), with hashed account keys and a bounded number
of buckets. Plan §4.4 step 0 and §4.11 record the decision per path.

Closes #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ

This branch was successfully deployed

1 active deployment
Preview — b4c55945 Deployed Oct 16, 2026 by vercel[bot]
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.

Per-client limit on the unauthenticated /auth/v1 proxy beyond email endpoints

2 participants