Skip to content

fix(auth): per-client email budget in front of Supabase Auth - #217

Merged
JOY (JOY) merged 2 commits into
mainfrom
fix/auth-email-budget
Oct 16, 2026
Merged

JOY (JOY) merged 2 commits into
mainfrom
fix/auth-email-budget

Conversation

@JOY

Copy link
Copy Markdown
Contributor

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/v1 proxy 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 /user with an email, /reauthenticate.

  • Per client address: at most 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 with TRUSTED_PROXY_HOPS the entry the trusted proxy appended; client-supplied forwarding headers are still dropped.
  • Per recipient: one every AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS (default 60), whichever client asks.
  • Over either: 429 in Supabase Auth's own shape ({"code":429,"error_code":"over_email_send_rate_limit","msg":...}) with Retry-After; nothing is forwarded, so the SPA (auth-js) sees the error code it already knows.
  • Release: a request Supabase Auth answers with 4xx/5xx sent nothing and gives its slot back. One it does not answer in time keeps it, since it may still have sent.
  • Body parsing: keys are matched the way Supabase Auth's Go decoder matches them (any case, any Content-Type). Whatever the proxy cannot read with certainty still counts per client (an unparseable body, conflicting recipients).
  • Fail closed: if the limiter cannot decide (database error), the proxy answers 503.

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 without org_id, with RLS on, no policy and no grants.
  • crove_kernel.auth_email_reserve() / auth_email_release(): SECURITY DEFINER with a pinned search_path, executable by crove_app only. Reserve counts under per-client then per-recipient advisory locks with a 3 s lock_timeout, restored afterwards, so it never waits in a cycle. A wait past the timeout is refused as auth_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:

  • Holds: across API instances and restarts. In-memory buckets would multiply with every instance and reset on deploy.
  • Cost: one write per email-sending request. Supabase Auth already writes a row per sign-up, so outsiders gain no new write amplification.

Budget and configuration

  • docker-compose.yml and .env.example now set GOTRUE_RATE_LIMIT_EMAIL_SENT from AUTH_RATE_LIMIT_EMAIL_SENT (default 100 per hour; size it to the SMTP quota) and GOTRUE_RATE_LIMIT_OTP from AUTH_RATE_LIMIT_OTP (default 30). The kernel gets the same budget value.
  • Startup guard: on a public URL the kernel refuses to start when fewer than 10 client addresses could use up the budget (AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR x 10 > AUTH_RATE_LIMIT_EMAIL_SENT).
  • Proxy warning: the proxy logs once when requests carry X-Forwarded-For while TRUSTED_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:

  • A client with many addresses (a botnet, or many IPv6 /64s) can still use up the budget.
  • The per-recipient interval can delay one person's email by a minute at a time, as Supabase Auth's own 60 s per-user frequency already does.
  • Users behind one office NAT share its per-client cap.

Tests

  • apps/kernel/test/auth-email-limit.test.ts (unit):
    • which requests count, including case-variant and conflicting JSON keys and anonymous sign-up
    • IPv4/IPv6 buckets
    • the configuration guard
    • the proxy with an in-memory limiter: 429 shape and Retry-After, spoofed X-Forwarded-For ignored with 0 hops, trusted hop used with 1
    • release on upstream 4xx, slot kept on timeout, 503 when the limiter fails, sign-up not counted with autoconfirm
  • apps/kernel/test/invariant/auth-email-limit.db.test.ts (PostgreSQL 17):
    • EXECUTE matrix (crove_app only, pinned search_path); the table is closed to crove_app and crove_worker
    • the per-client cap does not block another client and writes nothing on refusal
    • the rolling hour frees a slot
    • per-recipient interval across clients; release only by the holder
    • 12 concurrent reservations with limit 4: exactly 4 win
    • the sweep; malformed arguments refused
  • apps/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:
    • client A signs up 30 new addresses: 2 go through (cap 2), 28 get 429. Client B still gets its confirmation and recovery emails.
    • an IPv6 /64 counts as one client
    • the per-recipient rule works across clients, and refused requests give their slot back
    • control: without the cap, the same flood blocks client B with over_email_send_rate_limit
  • Manual check on a real compose stack (Supabase Auth v2.197.0, AUTH_AUTOCONFIRM=false, cap 2, budget 20, isolated project, torn down after):
    • from the host: 4 sign-ups gave 200, 200, 429, 429. A recovery with a spoofed X-Forwarded-For still got 429 with Retry-After: 3600.
    • from another client address (inside the kernel container): sign-up 200, and the confirmation arrived in Mailpit.
    • the table held hashed rows for 2 client buckets; the untrusted-proxy warning was logged once.

Commands run:

  • pnpm -s lint, pnpm -s typecheck, pnpm docs:check: pass
  • pnpm exec vitest run: 374 passed (after the final rebase)
  • invariant suite: 293 passed
  • db project: 331 passed (2 files skipped: S3)

Notes for other PRs

  • E2e PRs (feat: SPA auth flows: PKCE callbacks, password recovery, TOTP MFA and step-up #101 / test(auth): verify Supabase Auth rate limits through the /auth/v1 proxy #191): e2e/compose.e2e.yml turns confirmations off and runs many sign-ups and recoveries from one runner address. Once this merges it needs AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR (and AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS: 0 for repeated recoveries) raised on the crove service, as it already raises GOTRUE_RATE_LIMIT_EMAIL_SENT.
  • Protected paths (apps/kernel/src/http.ts, apps/kernel/src/auth-email-limit.ts, the migration, a new invariant file, AGENTS.md):
    • an independent security review ran in-session.
    • Blocking finding, fixed: case-variant JSON keys ({"Email": ...}) bypassed the count on PUT /user.
    • Should-fix, applied: keep the slot when Supabase Auth does not answer.
    • The remaining should-fix items are documented as residual risks.

Closes #190

🤖 Generated with Claude Code

JOY (JOY) and others added 2 commits October 16, 2026 08:22
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>
@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 1:24am UTC

Request Review

@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.

@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 10 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: ed318da1-49db-497e-afeb-b7846828bf55
📥 Commits

Reviewing files that changed from the base of the PR and between ea767d3 and c1023c5.

📒 Files selected for processing: 12
  • .env.example
  • AGENTS.md
  • README.md
  • apps/kernel/src/auth-email-limit.ts
  • apps/kernel/src/config.ts
  • apps/kernel/src/http.ts
  • apps/kernel/test/auth-email-limit.test.ts
  • apps/kernel/test/auth-email-proxy.db.test.ts
  • apps/kernel/test/invariant/auth-email-limit.db.test.ts
  • docker-compose.yml
  • docs/plan/crove-os-kernel.md
  • supabase/migrations/20261017030000_crove_kernel_auth_email_limit.sql
  • 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.

@gemini-code-assist gemini-code-assist 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

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.

Comment thread apps/kernel/src/http.ts
Comment on lines +192 to 213
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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);

@JOY
JOY (JOY) merged commit e0723bb into main Oct 16, 2026
13 checks passed
JOY (JOY) pushed a commit that referenced this pull request Oct 16, 2026
#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
JOY (JOY) pushed a commit that referenced this pull request Oct 16, 2026
#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

This branch was successfully deployed

1 active deployment
Preview — c1023c5b 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.

Supabase Auth email budget is one bucket per deployment: one client can block confirmation and recovery emails for an hour

1 participant