Auth tiers

Lesson 3 · about 15 minutes · three roles, two header names, two key formats, and the one place each is checked.

Every request lands in one of three Postgres roles: anon, authenticated or service_role. What decides the role is a key or a JWT in a header. What decides what the role can see is RLS. Auth bugs here are almost always one of two things: the wrong header was read, or a policy was written for the wrong role. This lesson gives you the map for both.

The three roles

RoleHow a request gets itRLSTypical caller
anonapikey: <anon or publishable key>, no user JWTEnforced. Policies TO anon with USING (true) expose public data such as Companies.App before sign-in, crawlers, stock details
authenticatedSame apikey plus Authorization: Bearer <user JWT> issued by GoTrueEnforced. Policies use auth.uid() = user_id.Signed-in app: watchlists, profile, brokerage
service_roleThe secret key, or a JWT whose role claim is service_roleBypassed.Cron via vault, triggers, edge functions writing ingested data

Two key formats coexist. Legacy JWT-shaped keys (anon, service_role) and the newer sb_publishable_* / sb_secret_* keys. The local .env carries PUBLISHABLE_KEY and SECRET_KEY; the platform injects SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. Names starting with SUPABASE_ cannot be set by hand in function secrets, which is why the modern pair has bare names. stocks/utils/auth.ts:17-22

Where each check lives

1Platform gate, verify_jwt. On by default, it rejects any request without a valid JWT before our code runs. All-or-nothing per function, so every function except submit-feedback turns it off and checks per endpoint. Locally --no-verify-jwt does the same for all of them. config.toml [functions.*] · stocks/utils/auth.ts:7-11
2Public endpoints, verifyPublicAuth. Reads apikey first, then Authorization. Accepts the publishable key or the secret key. Returns {authorized, isServiceRole} so a handler can offer extra behaviour to internal callers. Only the modern keys are accepted; legacy keys were removed. stocks/utils/auth.ts:65-105
3Internal endpoints, two implementations. The stocks function uses verifyServiceRoleAuth, a plain string compare against SECRET_KEY. Everything else uses _shared/jwt/default.ts validateServiceRole, which compares against SUPABASE_SERVICE_ROLE_KEY in constant time, then falls back to verifying the bearer as a JWT via JWKS and checking role === 'service_role'. stocks/utils/auth.ts:118-149 · _shared/jwt/default.ts:70-95
4User endpoints, getUserId. Builds a client with the service key but forwards the request's Authorization header, then calls auth.getClaims(jwt) and reads sub. Throws on anything missing. The handler then uses that id, and usually a user-scoped client so RLS still applies. _shared/jwt/default.ts:35-58
5Postgres, RLS. Whatever role the client key implied, the policies decide rows. 175 CREATE POLICY statements across the migrations. Tables that must never be read anonymously revoke anon outright so misuse raises rather than returning nothing. migrations/20260630181256_create_user_watchlist_table.sql:24-57
Why apikey is read before Authorization. Kong may rewrite apikey into an Authorization JWT, and locally it strips a bearer that is not a JWT, which the new sb_secret_* format is not. So the header a handler can trust to arrive unchanged is apikey. Both auth files say this in their comments; it is the single most common cause of "works in prod, 401 locally". stocks/utils/auth.ts:69-72 · _shared/jwt/default.ts:72-74

CORS is not auth, but it runs first

The Origin allow-list in _shared/cors.ts is a browser-protection layer: it stops other sites' JavaScript from using the API with a user's cookies. It proves nothing about who is calling, since any server can set any Origin. It matters for auth only because it runs before auth, so a missing Origin gives a 403 that looks like an auth failure. Functions that never import _shared/cors, such as fx and proxy, have no such gate, which is why the cron integrity test scopes its Origin check to the functions that do. tests/database/cron-job-integrity.test.sql:66-72

What config.toml [auth] controls, and what it does not

SettingEffectReaches prod?
jwt_expiry = 3600, refresh rotation onAccess tokens last an hour; refresh tokens are single use.No. The GitHub integration applies migrations and functions only. Prod auth is set by hand in the dashboard; the file is kept in sync so local matches. config.toml:77-79 · docs/SOCIAL_SIGN_IN.md:48-52
minimum_password_length = 12, email confirmations onGoTrue signup rules.
[auth.external.apple], [auth.external.google]Native sign-in: the app sends an identity token to signInWithIdToken; client_id is the comma-separated list of accepted audiences. Google has skip_nonce_check = true for the whole provider, a documented downgrade.
[auth.rate_limit] email_sent = 2000Must match prod; the scaffold default of 2 blocked magic links.

Check yourself

Do this in the repo

  1. With the stack served, hit an internal endpoint three ways and predict each status: no key; -H "apikey: $SECRET_KEY"; -H "Authorization: Bearer $SECRET_KEY". All with -H 'Origin: http://localhost:5173' against http://localhost:54321/functions/v1/stocks/refresh-market-cap.
  2. Find every policy on user_watchlist and say which role each targets: grep -n 'create policy' -A4 src/supabase/migrations/20260630181256_create_user_watchlist_table.sql.
  3. Read _shared/jwt/default.ts top to bottom and mark the one line where a JWT's signature is actually verified.

Primary source

Supabase: Verifying a JWT, the doc _shared/jwt/default.ts was copied from, then Row Level Security for auth.uid() and the role model.