Repository navigation
fix(auth): per-client email budget in front of Supabase Auth - #217
Conversation
Supabase Auth sends every email from one deployment-wide bucket (GOTRUE_RATE_LIMIT_EMAIL_SENT), so one client could use it up and stop confirmation and recovery emails for everyone for an hour. The /auth/v1 proxy now reserves a slot in the database before forwarding a request that sends an email: at most AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR per client address (IPv6 per /64) per rolling hour and one per recipient every AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS, 429 over_email_send_rate_limit with Retry-After otherwise. Refused upstream requests give the slot back. The count runs in crove_kernel.auth_email_reserve() under advisory locks, so it holds across API instances; only hashes are stored and expired rows are swept. Compose and .env.example expose the Supabase Auth email and OTP limits, and a public deployment refuses to start when fewer than 10 client addresses could use up the budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…meout
Supabase Auth decodes bodies with Go's encoding/json, which matches keys
without regard to case, so {"Email": ...} on PUT /user skipped the count
while still sending the email-change mail. Keys are now matched the same
way, conflicting recipients count with no recipient, and anonymous sign-up
(no email or phone) is not counted. A request Supabase Auth does not answer
in time keeps its slot, since it may still have sent the email.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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.
|
Warning Review limit reachedYou'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 10 minutes. View limit details
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements a per-client email rate limiting mechanism in front of Supabase Auth (addressing issue #190) to prevent a single client from exhausting the deployment-wide email budget. It introduces a database-backed reservation system with advisory locks, client IP bucket mapping (supporting IPv6 /64), and recipient tracking, alongside comprehensive unit, integration, and invariant tests. The review feedback suggests a performance optimization in the HTTP proxy layer to avoid an unnecessary memory copy of the request body ArrayBuffer when forwarding payloads to the upstream service.
| const payload = method === 'GET' || method === 'HEAD' ? null : new Uint8Array(await c.req.arrayBuffer()); | ||
| // A request that makes Supabase Auth send an email takes a slot of its client's budget first (#190). | ||
| const sends = emailSendRequest(method, path, payload, autoconfirm); | ||
| const client = clientBucket(ip); | ||
| let reservation: string | null = null; | ||
| if (sends) { | ||
| warnIfUntrustedProxy(c, config.TRUSTED_PROXY_HOPS); | ||
| const verdict = await emailLimiter.reserve({ client, target: sends.target }).catch((error: unknown) => { | ||
| console.error('[auth-proxy] email limiter unavailable:', error instanceof Error ? error.message : error); | ||
| return null; | ||
| }); | ||
| if (!verdict) return c.json({ code: 'AUTH_UNAVAILABLE', message: 'Dịch vụ đăng nhập không phản hồi' }, 503); | ||
| if (!verdict.allowed) return emailRateLimited(verdict.retryAfterSeconds); | ||
| reservation = verdict.reservation; | ||
| } | ||
| const response = await fetch(`${upstream}${path}${url.search}`, { | ||
| method, | ||
| headers, | ||
| body: method === 'GET' || method === 'HEAD' ? null : await c.req.arrayBuffer(), | ||
| body: payload ? body(payload) : null, | ||
| redirect: 'manual', | ||
| signal: AbortSignal.timeout(15_000), | ||
| }).catch(() => null); |
There was a problem hiding this comment.
By converting the ArrayBuffer to a Uint8Array and then calling body(payload) (which slices and copies the underlying buffer), an unnecessary memory copy of the request body is performed on every proxy request. We can avoid this overhead by keeping the original ArrayBuffer and passing it directly to fetch as the request body, while only creating the Uint8Array view for parsing the email recipient target.
const arrayBuffer = method === 'GET' || method === 'HEAD' ? null : await c.req.arrayBuffer();
const payload = arrayBuffer ? new Uint8Array(arrayBuffer) : null;
// A request that makes Supabase Auth send an email takes a slot of its client's budget first (#190).
const sends = emailSendRequest(method, path, payload, autoconfirm);
const client = clientBucket(ip);
let reservation: string | null = null;
if (sends) {
warnIfUntrustedProxy(c, config.TRUSTED_PROXY_HOPS);
const verdict = await emailLimiter.reserve({ client, target: sends.target }).catch((error: unknown) => {
console.error('[auth-proxy] email limiter unavailable:', error instanceof Error ? error.message : error);
return null;
});
if (!verdict) return c.json({ code: 'AUTH_UNAVAILABLE', message: 'Dịch vụ đăng nhập không phản hồi' }, 503);
if (!verdict.allowed) return emailRateLimited(verdict.retryAfterSeconds);
reservation = verdict.reservation;
}
const response = await fetch(upstream + path + url.search, {
method,
headers,
body: arrayBuffer,
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
}).catch(() => null);#217 limits email-sending auth requests to 5 per client address per hour and one per recipient a minute, in the proxy. The e2e tests sign up and recover from the runner's single address, often right after mailing the same recipient, so the stack raises both limits and Supabase Auth's deployment-wide budget, keeping the 10-client ratio the kernel enforces at start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
#217 limits email-sending auth requests to 5 per client address per hour and one per recipient a minute, in the proxy. The e2e tests sign up and recover from the runner's single address, often right after mailing the same recipient, so the stack raises both limits and Supabase Auth's deployment-wide budget, keeping the 10-client ratio the kernel enforces at start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
Problem
Supabase Auth v2.197.0 sends every email from one deployment-wide bucket (
GOTRUE_RATE_LIMIT_EMAIL_SENT, default 30 per hour, counted while confirmations are on). Its per-address limits (GOTRUE_RATE_LIMIT_OTP, a burst of 30) are larger than that bucket, so one client could sign up 30 new addresses and stop confirmation and recovery emails for everyone for an hour (#190).Design
The kernel's
/auth/v1proxy now reserves a slot in PostgreSQL before it forwards a request that makes Supabase Auth send an email: sign-up (while confirmations are on),/recover,/otp,/resend,PUT /userwith an email,/reauthenticate.AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR(default 5) in any rolling hour. IPv4 counts per address, IPv6 per /64 (one host usually holds a whole /64), IPv4-mapped IPv6 as IPv4. The address is the one the proxy already trusts for Supabase Auth's limits: the socket, or withTRUSTED_PROXY_HOPSthe entry the trusted proxy appended; client-supplied forwarding headers are still dropped.AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS(default 60), whichever client asks.{"code":429,"error_code":"over_email_send_rate_limit","msg":...}) withRetry-After; nothing is forwarded, so the SPA (auth-js) sees the error code it already knows.DB-backed vs in-memory
DB-backed, as the plan prefers for security limits (same pattern as the org creation and inbound rotation limits). Migration
20261017030000_crove_kernel_auth_email_limit.sql:crove_kernel.auth_email_send: SHA-256 of the client bucket and of the recipient, plus a timestamp. No address or email is stored. It is a global table withoutorg_id, with RLS on, no policy and no grants.crove_kernel.auth_email_reserve()/auth_email_release(): SECURITY DEFINER with a pinnedsearch_path, executable bycrove_apponly. Reserve counts under per-client then per-recipient advisory locks with a 3 slock_timeout, restored afterwards, so it never waits in a cycle. A wait past the timeout is refused asauth_email_busy. It writes nothing when it refuses, and each call sweeps up to 100 rows older than an hour (SKIP LOCKED), so the table holds about one hour of traffic.Trade-offs:
Budget and configuration
docker-compose.ymland.env.examplenow setGOTRUE_RATE_LIMIT_EMAIL_SENTfromAUTH_RATE_LIMIT_EMAIL_SENT(default 100 per hour; size it to the SMTP quota) andGOTRUE_RATE_LIMIT_OTPfromAUTH_RATE_LIMIT_OTP(default 30). The kernel gets the same budget value.AUTH_EMAIL_CLIENT_LIMIT_PER_HOURx 10 >AUTH_RATE_LIMIT_EMAIL_SENT).X-Forwarded-ForwhileTRUSTED_PROXY_HOPS=0. Behind a reverse proxy every client would then share one budget.CAPTCHA decision
CAPTCHA is not on by default. The OSS core must run with no third-party service. Optional Turnstile/hCaptcha on sign-up and recovery is follow-up #203.
Residual risks, written in the plan's
/auth/v1/*row:Tests
apps/kernel/test/auth-email-limit.test.ts(unit):Retry-After, spoofedX-Forwarded-Forignored with 0 hops, trusted hop used with 1apps/kernel/test/invariant/auth-email-limit.db.test.ts(PostgreSQL 17):crove_apponly, pinnedsearch_path); the table is closed tocrove_appandcrove_workerapps/kernel/test/auth-email-proxy.db.test.ts: through the real proxy and DB limiter, in front of a fake Supabase Auth with one deployment-wide bucket:over_email_send_rate_limitAUTH_AUTOCONFIRM=false, cap 2, budget 20, isolated project, torn down after):X-Forwarded-Forstill got 429 withRetry-After: 3600.Commands run:
pnpm -s lint,pnpm -s typecheck,pnpm docs:check: passpnpm exec vitest run: 374 passed (after the final rebase)Notes for other PRs
e2e/compose.e2e.ymlturns confirmations off and runs many sign-ups and recoveries from one runner address. Once this merges it needsAUTH_EMAIL_CLIENT_LIMIT_PER_HOUR(andAUTH_EMAIL_ADDRESS_INTERVAL_SECONDS: 0for repeated recoveries) raised on thecroveservice, as it already raisesGOTRUE_RATE_LIMIT_EMAIL_SENT.apps/kernel/src/http.ts,apps/kernel/src/auth-email-limit.ts, the migration, a new invariant file,AGENTS.md):{"Email": ...}) bypassed the count onPUT /user.Closes #190
🤖 Generated with Claude Code