Written 2026-10-26, after MR !49 (ES256 only, Go 1.26).
Two parties are involved, and they never talk to each other about a user:
0ae34dbc-…, valid for an hour."The token is the only thing that passes between the two systems. Supabase's database and this app's Postgres hold no data in common, and the user's UUID inside the token is the only link between them.
A JWT is three base64 pieces joined by dots: header.payload.signature.
{"alg":"ES256","kid":"9b04…"}. This names the algorithm and which of Supabase's keys signed it.{ "sub": "0ae34dbc-…", "email": "…", "aud": "authenticated",
"iss": "https://<project>.supabase.co/auth/v1", "exp": 1730000000,
"user_metadata": { … } }
So anyone can read a JWT, because it isn't encrypted. Nobody can change one: editing a single byte of sub breaks the signature. That's why the API can trust sub as "who is calling."
Signing in (mobile/src/screens/SignInScreen.tsx):
supabase.auth.signInWithOtp({ email }) makes Supabase email a code.supabase.auth.verifyOtp(...) with the typed code gets back a session: an access_token (the JWT, about 1 hour) and a refresh_token (long-lived, used to get new JWTs).Keeping it alive (mobile/src/lib/supabase.ts):
persistSession: true saves the session in AsyncStorage, so it survives app restarts.autoRefreshToken: true makes the Supabase client trade the refresh token for a fresh JWT in the background before the old one expires.Handing it to the API (mobile/src/auth/AuthContext.tsx):
adopt(session) calls setAccessToken(session.access_token) in api/client.ts. This runs on startup (getSession()) and on every onAuthStateChange: sign-in, sign-out and each background refresh.session.user.id, so one account's queued writes never go out under another's token.Using it (mobile/src/api/client.ts): each request sends Authorization: Bearer ${accessToken}. If there's no token, it throws ApiError(401) without touching the network.
Signing out is signOut({ scope: 'local' }). That throws away this phone's tokens only. The library's default (global) would revoke the refresh tokens on every device.
Startup (backend/cmd/api/main.go, internal/config/config.go):
SUPABASE_URL. Two values are derived from it:
<SUPABASE_URL>/auth/v1/.well-known/jwks.json (Supabase's public keys)<SUPABASE_URL>/auth/v1auth.NewVerifier fetches the JWKS through keyfunc and keeps refreshing it in the background. If Supabase rotates its keys, the API picks up the new ones without a redeploy.Every request under /v1 goes through verifier.Middleware (internal/httpapi/router.go). /healthz doesn't. The middleware lives in backend/internal/auth/auth.go:
Authorization: Bearer <token>. If the header is missing or malformed, the answer is 401.jwt.Parse with:
WithValidMethods("ES256"). Only the algorithm Supabase actually uses is accepted, which also blocks the classic alg: none / algorithm-swap attacks. (RS256 was accepted too until MR !49; the live JWKS has only ever published an ES256 key.)kid matches the header.WithIssuer(...), so a token from a different Supabase project is rejected.WithAudience("authenticated"), so the anon key (whose aud is not authenticated) can't be used as a login.WithExpirationRequired(), so a token must have an exp and it must lie in the future.profileFromClaims): sub must be a UUID, because that becomes users.id. It also picks up email, plus a display name from user_metadata (falling back to the part of the email before @) and an avatar.EnsureUser upserts the users row. This is how a brand-new Supabase user exists in this database at all, since there's no "create account" endpoint. The timestamp touch is rate-limited to once every 10 s.auth.UserID(ctx). This is the only source of "who did this": no endpoint accepts created_by from the body.auth_test.go covers this: a key that isn't in the JWKS (TestMiddlewareRejectsForeignSigningKey), wrong issuer, wrong audience, expired or missing exp, and a bad sub are all rejected without provisioning anyone.
onAuthStateChange fires, and client.ts gets the new token.chaixp3+1 on the emulator.