- 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>
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 mintservice_roletokens; 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.