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()inapps/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 β thegenerateLink+verifyOtpcalls 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):
-
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.) -
Use a modern secret API key. GoTrue only honours
Sb-Forwarded-Forfor requests made with a secret API key (sb_secret_...). The legacyservice_role/anonkeys are ignored. Set the worker secretSUPABASE_SECRET_KEY(seeworkers/api/src/types/env.ts) to the projectβs secret key; the code prefers it and falls back toSUPABASE_SERVICE_ROLE_KEYwhen 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.