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
| Role | How a request gets it | RLS | Typical caller |
|---|---|---|---|
anon | apikey: <anon or publishable key>, no user JWT | Enforced. Policies TO anon with USING (true) expose public data such as Companies. | App before sign-in, crawlers, stock details |
authenticated | Same apikey plus Authorization: Bearer <user JWT> issued by GoTrue | Enforced. Policies use auth.uid() = user_id. | Signed-in app: watchlists, profile, brokerage |
service_role | The secret key, or a JWT whose role claim is service_role | Bypassed. | 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
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-11verifyPublicAuth. 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-105verifyServiceRoleAuth, 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-95getUserId. 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-58CREATE 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-57apikey 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-74CORS 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
| Setting | Effect | Reaches prod? |
|---|---|---|
jwt_expiry = 3600, refresh rotation on | Access 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 on | GoTrue 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 = 2000 | Must match prod; the scaffold default of 2 blocked magic links. |
Check yourself
Do this in the repo
- 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'againsthttp://localhost:54321/functions/v1/stocks/refresh-market-cap. - Find every policy on
user_watchlistand say which role each targets:grep -n 'create policy' -A4 src/supabase/migrations/20260630181256_create_user_watchlist_table.sql. - Read
_shared/jwt/default.tstop 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.
Next: Lesson 4, migrations and pgTAP: how those policies get to prod and how a test proves they are still there. Back: Lesson 2.
Ask your teacher in the terminal: "why does stocks have its own auth module instead of _shared" is a fair question with a historical answer.