Files
Gebos/docs/adr/0004-legacy-jwt-api-keys.md
Lars Nolden 2ce68cb098
deploy / deploy (push) Successful in 5m57s
check / flake-check (push) Failing after 35m52s
Wire schema v1 into a manual Supabase migration and vendor the real stack
- Move db/schema.sql to supabase/migrations/ as the first supabase CLI
  migration (manual `db push` only, no automated runner); re-add the
  gebos_ingest role + grants there since init.sql never re-runs
- Add gebos-postgres-passwords oneshot on db-host: syncs role passwords
  from sops (LoadCredential, journal-safe), makes supabase_admin
  SUPERUSER and hands the auth schema to supabase_auth_admin to match
  the upstream supabase/postgres image; add pg_hba rule for 10.0.0.0/8
- Vendor the official docker-compose (studio/kong/auth/rest/meta only,
  external Postgres, loopback Studio with no Kong dashboard route) plus
  kong.yml (trimmed) and kong-entrypoint.sh (verbatim); tested: compose
  config, Kong config parse, migration applied on TimescaleDB pg17
- Document decisions as ADRs 0001-0004 (migrations, passwords, vendored
  stack, JWT API keys incl. verify/mint procedure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:33:50 +02:00

104 lines
4.4 KiB
Markdown

# 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":"<user uuid>",…}` |
| 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 <jwt> — 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 <role> — 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.