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

4.4 KiB

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:

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:

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.