Skip to content

Supabase Auth per-end-user rate limiting (`Sb-Forwarded-For`)

Issue: #3115.

Problem

Supabase Auth (GoTrue) rate-limits token/session operations at 1,800 requests/hr per IP (burst 30), keyed on the IP GoTrue sees.

  • Browser refreshes (the hot path of every conversion β€” getSession() in apps/web/src/lib/api/client.ts) go directly from the end user’s browser to GoTrue, so they already carry the user’s own IP. There is no proxy/worker in that path to collapse the IP. Nothing to fix client-side.
  • Server-side session minting is different. When we mint a session on a user’s behalf β€” SAML SSO (workers/api/src/routes/sso.ts) and LTI (workers/api/src/routes/lti.ts) federation β€” the generateLink + verifyOtp calls originate from the Cloudflare Worker, so GoTrue sees the worker’s egress IP for every user. A whole university federating through one tenant collapses into a single 1,800/hr bucket and, at scale, starts getting 429s.

The Sb-Forwarded-For header tells GoTrue to bucket on the forwarded IP instead.

What the code does

workers/api/src/utils/supabase.ts β€” createSupabaseClient(env, forwardedFor?) injects Sb-Forwarded-For into the client’s global.headers when passed a trusted, validated end-user IP. The SSO and LTI session-minting flows now build a dedicated client with the end-user IP (clientIp(c) β†’ Cloudflare’s cf-connecting-ip) for the generateLink/verifyOtp calls.

Trust boundary (security)

Sb-Forwarded-For is a rate-limit-evasion vector if sourced from untrusted input. The code only ever forwards cf-connecting-ip, which Cloudflare sets and the client cannot spoof (see utils/sso-http.ts clientIp). It never passes a raw client-supplied X-Forwarded-For / Forwarded / client-set Sb-Forwarded-For through. normalizeForwardedIp additionally validates the value is a single well-formed IPv4/IPv6 address (no list, no CRLF) before it can reach the outbound header.

Required project configuration (dashboard / Management API)

Two things must be true in the Supabase project for the header to take effect β€” until then the code is a harmless no-op (GoTrue ignores the header):

  1. Enable IP address forwarding. Dashboard β†’ Authentication β†’ Rate Limits β†’ IP Address Forwarding, or via the Management API:

    Terminal window
    curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
    -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"security_sb_forwarded_for_enabled": true}'

    (Project ref for prod: vuvwmfxssjosfphzpzim.)

  2. Use a modern secret API key. GoTrue only honours Sb-Forwarded-For for requests made with a secret API key (sb_secret_...). The legacy service_role / anon keys are ignored. Set the worker secret SUPABASE_SECRET_KEY (see workers/api/src/types/env.ts) to the project’s secret key; the code prefers it and falls back to SUPABASE_SERVICE_ROLE_KEY when unset. The header does nothing until this secret is configured.

    Terminal window
    # In workers/api:
    npx wrangler secret put SUPABASE_SECRET_KEY

Verifying

From two different simulated client IPs behind one worker, mint sessions via the SSO/LTI callback and confirm the two IPs consume separate rate-limit buckets (one exhausting its 1,800/hr does not 429 the other). The normalizeForwardedIp / header-injection logic is unit-tested in workers/api/src/__tests__/utils/supabase.test.ts.

Follow-up (out of scope here)

The browser client treats a 429 on refresh as an unusable session and signs the user out (getAccessToken β†’ handleSessionExpired, related to #2188). A distinct β€œrate limited, retry shortly” state β€” so a 429 is never silently a logout β€” should be tracked separately.