fix(supabase): generate the new API keys and wire up JWKS verification - #1069
Conversation
The template has carried the plumbing for Supabase's new opaque API keys since Dokploy#872 — kong-entrypoint.sh builds the Lua translation expressions and kong.yml registers the keys as key-auth credentials — and Dokploy#1068 added the studio/kong compose references while aligning with upstream. But four of the variables that plumbing reads have never been declared in template.toml, so they never reached the Environment tab: SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY, ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC kong-entrypoint.sh gates on the first two being non-empty, so every deploy silently took the legacy-only branch and users had no field to fill in. Generate them from [variables] instead: - SUPABASE_PUBLISHABLE_KEY / SUPABASE_SECRET_KEY as opaque sb_* strings - ANON_KEY / SERVICE_ROLE_KEY move to [variables] so ANON_KEY_ASYMMETRIC and SERVICE_ROLE_KEY_ASYMMETRIC can reuse the same JWT Kong substitutes whatever token those hold and every backend already verifies HS256 with JWT_SECRET, so the opaque keys work without an EC keypair (which the dokploy helpers cannot generate). Clients still only ever hold an opaque string. JWT_KEYS / JWT_JWKS stay empty as a documented opt-in for real ES256. Also fixed while in here: - JWT_JWKS was missing entirely. GOTRUE_JWT_KEYS was already enabled, so anyone setting a real JWKS got Auth signing ES256 tokens that PostgREST, Storage, Realtime and Functions had no way to verify. Wire it into all four verifiers. - Restore two routes kong.yml dropped: /realtime/v1/api/tenants and /realtime/v1/api/openapi were reachable with the anon key, and the PostgREST OpenAPI root at /rest/v1/ was anon-readable (supabase#42949). Needs KONG_ROUTER_FLAVOR: expressions, as upstream sets. - graphql-v1 had key-auth hide_credentials: true. key-auth runs before request-transformer, so the apikey header was stripped before the Lua expression could read it and service_role GraphQL requests were silently downgraded to anon. - DB_ENC_KEY was hardcoded to the well-known "supabaserealtime" default; generate REALTIME_DB_ENC_KEY instead. - functions/main/index.ts was the old remote-JWKS version pinned to deno.land/x/jose@4.14.4; sync with upstream (jsr:@panva/jose@6 and local SUPABASE_JWKS). Verified against all 11 services running locally: an RLS-protected table confirms anon and publishable keys are blocked while service_role and secret keys read the row, so the opaque keys really are translated and not downgraded. Storage and Realtime still verify HS256 with JWT_JWKS empty. 21/22 end-to-end checks pass; the last was the test not following Studio's redirect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0940d4d to
f7e7a55
Compare
…ot bytes)
Dokploy's generateHash(length) returns exactly `length` hex characters,
not `length` random bytes hex-encoded. Realtime requires DB_ENC_KEY to be
exactly 16 characters (AES-128), so ${hash:8} produced an 8-char key and
the realtime container crash-looped. Bump to ${hash:16}, and bump the
opaque API key segments to ${hash:22}/${hash:8} to match the intended
sb_<role>_<22>_<8> shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
built with Refined Cloudflare Pages Action⚡ Cloudflare Pages Deployment
|
|
Tested this on a real Dokploy instance (hostinger.dokploy.com) — deployed the template as-is from this branch, all 11 services up. Found and fixed one bug (pushed to your branch, Bug found: dokploy's
|
| Check | Result |
|---|---|
GET /rest/v1/ + sb_secret_… |
200 OpenAPI JSON — opaque secret key translated to service_role, passes the new admin-only ACL |
GET /rest/v1/ + sb_publishable_… |
403 "You cannot consume this service" — authenticated as anon, correctly denied on admin-only root |
GET /rest/v1/ + legacy SERVICE_ROLE_KEY / ANON_KEY |
200 / 403 — legacy keys keep working identically |
GET /rest/v1/ no key |
401 |
GET /rest/v1/<table> + publishable |
PostgREST PGRST205 — swapped-in anon JWT verifies at PostgREST (past auth, into schema cache) |
GET /auth/v1/health + publishable |
200 GoTrue v2.189.0 |
GET /auth/v1/.well-known/jwks.json |
200 {"keys":[]} (expected with JWT_KEYS=[]) |
/realtime/v1/api/tenants, /realtime/v1/api/openapi |
403 "Access is forbidden." (new blocks work) |
| Realtime WS handshake | identical behavior for publishable key vs legacy JWT (no regression from the key swap) |
GET /storage/v1/bucket + secret key |
200 [] — Storage verifies HS256 fine with JWT_JWKS empty |
POST /functions/v1/hello + publishable |
200 "Hello from Edge Functions!" |
POST /graphql/v1 + secret key |
reaches pg_graphql handler (no silent anon downgrade — hide_credentials fix works) |
| Studio via dashboard basic auth | 200 |
Also verified against upstream supabase/docker: Kong 3.9.3 + KONG_ROUTER_FLAVOR: expressions, PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}, DB_ENC_KEY: ${REALTIME_DB_ENC_KEY:-supabaserealtime}, the rest-v1-openapi expression route and both realtime blocks, and functions/main/index.ts matches upstream commit 9cf6ae1f. Upstream ships the three *_JWKS: ${JWT_JWKS:-{"keys":[]}} lines commented out for podman-compose compatibility, but Dokploy runs docker compose — I confirmed compose v5 interpolates the braced default correctly both when JWT_JWKS is empty and when set, so enabling them here is fine (and actually wires JWKS verification up, which was the point).
Backwards compatibility looks right too: every new compose reference has a :- fallback, so existing deployments with the old env keep working unchanged (DB_ENC_KEY falls back to supabaserealtime, Kong takes the legacy-only branch when the new keys are absent).
Nice PR — the writeup matched what I found in the code at every point. LGTM with the hash-length fix.
Thanks for #1068 — the Postgres 17 bump and the upstream realignment are what made this worth chasing down. This is a follow-up on the same blueprint.
What's missing
The template has carried the plumbing for the new opaque API keys since #872 —
kong-entrypoint.shbuilds the Lua translation expressions,kong.ymlregisters them as key-auth credentials — and #1068 added thestudio/kongcompose references.But four of the variables that plumbing reads have never been declared in
template.toml, so they never reach the Environment tab:SUPABASE_PUBLISHABLE_KEYSUPABASE_SECRET_KEYANON_KEY_ASYMMETRICSERVICE_ROLE_KEY_ASYMMETRICkong-entrypoint.shgates on the first two being non-empty, so every deploy silently takes the legacy-only branch and users have no field to fill in.Generating them
The dokploy helpers can't produce an EC P-256 keypair, but they don't need to. Kong substitutes whatever token the
*_ASYMMETRICvars hold, and every backend already verifies HS256 withJWT_SECRET— so the opaque keys work out of the box, and clients still only ever hold an opaque string rather than a decodable JWT.ANON_KEY/SERVICE_ROLE_KEYmove into[variables]so the asymmetric vars can reuse the same JWT.JWT_KEYS/JWT_JWKSstay empty as a documented opt-in for real ES256.Also fixed
JWT_JWKSwas missing entirely.GOTRUE_JWT_KEYSis already enabled, so anyone setting a real JWKS would get Auth signing ES256 tokens that PostgREST, Storage, Realtime and Functions have no way to verify — every authenticated request 401s. Now wired into all four verifiers.kong.ymlroutes are missing relative to upstream:/realtime/v1/api/tenantsand/realtime/v1/api/openapiare reachable with the anon key, and the PostgREST OpenAPI root at/rest/v1/is anon-readable (Breaking Change: Removing access to OpenAPI spec via the anon key supabase/supabase#42949). Restored; needsKONG_ROUTER_FLAVOR: expressions, as upstream sets.graphql-v1hashide_credentials: trueon key-auth. key-auth (priority 1250) runs before request-transformer (801), so theapikeyheader is stripped before the Lua expression can read it — service_role GraphQL requests are silently downgraded to anon.DB_ENC_KEYis hardcoded to the well-knownsupabaserealtimedefault; now generated asREALTIME_DB_ENC_KEY.functions/main/index.tsis the old remote-JWKS version pinned todeno.land/x/jose@4.14.4; synced with upstream (jsr:@panva/jose@6+ localSUPABASE_JWKS).Testing
Ran the template as dokploy materializes it (including its
KEY="value"$-escaping) with all 11 services up. The decisive check is an RLS-protected table: anon and publishable keys get[], service_role and secret keys read the row — confirming the opaque keys are genuinely translated to service_role and not downgraded. Storage and Realtime still verify HS256 withJWT_JWKSempty. 21/22 end-to-end checks pass; the last was my test not following Studio's/→/project/defaultredirect.One note, unrelated to this PR
build-scripts/helpers.tsdrifts from the real dokploy implementation in two ways that make local validation misleading:generatePasswordincludes!@#$%^&*(production is lowercase alphanumeric), andgenerateJwtdrops string payloads so every${jwt:secret:payload}resolves to{}. Happy to open a separate PR if useful.🤖 Generated with Claude Code