# ADR-0004: Legacy JWT-based API keys, one signing secret Date: 2026-07-08 Status: Accepted ## Context Supabase's API tier knows two credentials, and they are easy to confuse because in the legacy scheme **both are JWTs signed with the same `JWT_SECRET`**: | | `apikey` header | `Authorization: Bearer …` header | |---|---|---| | identifies | the application | a logged-in person | | minted by | a human, once, at project setup | GoTrue, at every login | | payload | `{"role":"anon"}` / `{"role":"service_role"}` | `{"role":"authenticated","sub":"",…}` | | lifetime | ~10 years | 1 h (`GOTRUE_JWT_EXP`), refreshed | | checked by | Kong: exact string match against its consumer list | PostgREST: signature verification, then `SET ROLE` on the claim | The `role` claim is what binds tokens to the database: PostgREST executes each request as the Postgres role the (verified) `Authorization` JWT names — `anon` for pre-login traffic, `authenticated` for sessions (with `auth.uid()` = the `sub` claim, feeding RLS), `service_role` for server-side admin work. `service_role` exists because some work legitimately spans all tenants (admin invites, cross-apartment aggregation jobs, backfills); it carries `BYPASSRLS` and its key must never leave the server side, while the anon key is public by design (it ships in the frontend bundle). Supabase is migrating to opaque `sb_publishable_*`/`sb_secret_*` keys that separate the two concepts; the vendored `kong-entrypoint.sh` contains the translation shim for that scheme. ## Decision Stay on the **legacy scheme** for the pilot: `supabase_anon_key` and `supabase_service_role_key` are long-lived HS256 JWTs signed with `supabase_jwt_secret`. The opaque-key shim stays dormant (its env vars are empty, which switches the Kong entrypoint to plain apikey pass-through). This has a non-obvious integrity requirement: **the three secrets form one cryptographic family.** The API keys are only valid if they were signed with the exact `JWT_SECRET` stored alongside them. They are minted offline by us — no service issues them — and rotating `JWT_SECRET` silently invalidates both API keys *and* every active user session; all three must always be rotated together. ## Verifying / minting the keys The keys currently in `nix/secrets/secrets.yaml` must be checked once against the stored secret (they predate this ADR). From the repo root, with sops access — `openssl` is not in the dev shell, so wrap in `nix shell nixpkgs#openssl` if needed: ```sh SECRET=$(sops -d --extract '["supabase_jwt_secret"]' nix/secrets/secrets.yaml) verify() { # verify — checks HS256 signature against $SECRET local hp=${1%.*} sig=${1##*.} local expect=$(printf '%s' "$hp" \ | openssl dgst -sha256 -hmac "$SECRET" -binary \ | basenc --base64url -w0 | tr -d '=') if [ -n "$expect" ] && [ "$sig" = "$expect" ]; then echo "valid ($(printf '%s' "${hp#*.}" | tr '_-' '/+' | base64 -d 2>/dev/null))" else echo "INVALID SIGNATURE" fi } verify "$(sops -d --extract '["supabase_anon_key"]' nix/secrets/secrets.yaml)" verify "$(sops -d --extract '["supabase_service_role_key"]' nix/secrets/secrets.yaml)" ``` Each should print `valid` with a payload naming the right role. If either prints `INVALID SIGNATURE`, re-mint and store both: ```sh mint() { # mint — 10-year HS256 JWT signed with $SECRET local iat=$(date +%s) b64='basenc --base64url -w0' local h=$(printf '{"alg":"HS256","typ":"JWT"}' | $b64 | tr -d '=') local p=$(printf '{"role":"%s","iss":"supabase","iat":%s,"exp":%s}' \ "$1" "$iat" $((iat + 315360000)) | $b64 | tr -d '=') local s=$(printf '%s.%s' "$h" "$p" \ | openssl dgst -sha256 -hmac "$SECRET" -binary | $b64 | tr -d '=') printf '%s.%s.%s\n' "$h" "$p" "$s" } mint anon # → sops set as supabase_anon_key mint service_role # → sops set as supabase_service_role_key ``` (Equivalently: the generator on Supabase's self-hosting docs page produces the same thing — paste in the stored `JWT_SECRET`, don't let it invent a new one.) ## Consequences * Matches what supabase-js sends by default; no client-side configuration beyond URL + anon key. * One secret to protect (`JWT_SECRET`) — and one blast radius: leak it and an attacker can mint `service_role` tokens; rotate it and keys + sessions die together. * Moving to opaque keys later is config-only: fill the four `sb_*`/asymmetric env vars and the already-vendored Kong entrypoint starts translating.