Skip to content

fix(supabase): generate the new API keys and wire up JWKS verification - #1069

Merged
Siumauricio merged 2 commits into
Dokploy:canaryfrom
whollacsek:fix/supabase-new-api-keys
Aug 7, 2026
Merged

fix(supabase): generate the new API keys and wire up JWKS verification#1069
Siumauricio merged 2 commits into
Dokploy:canaryfrom
whollacsek:fix/supabase-new-api-keys

Conversation

@whollacsek

@whollacsek whollacsek commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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 #872kong-entrypoint.sh builds the Lua translation expressions, kong.yml registers them as key-auth credentials — and #1068 added the studio/kong compose 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_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 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 *_ASYMMETRIC vars hold, and every backend already verifies HS256 with JWT_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_KEY move into [variables] so the asymmetric vars can reuse the same JWT. JWT_KEYS / JWT_JWKS stay empty as a documented opt-in for real ES256.

Also fixed

  • JWT_JWKS was missing entirely. GOTRUE_JWT_KEYS is 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.
  • Two kong.yml routes are missing relative to upstream: /realtime/v1/api/tenants and /realtime/v1/api/openapi are 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; needs KONG_ROUTER_FLAVOR: expressions, as upstream sets.
  • graphql-v1 has hide_credentials: true on key-auth. key-auth (priority 1250) runs before request-transformer (801), so the apikey header is stripped before the Lua expression can read it — service_role GraphQL requests are silently downgraded to anon.
  • DB_ENC_KEY is hardcoded to the well-known supabaserealtime default; now generated as REALTIME_DB_ENC_KEY.
  • functions/main/index.ts is the old remote-JWKS version pinned to deno.land/x/jose@4.14.4; synced with upstream (jsr:@panva/jose@6 + local SUPABASE_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 with JWT_JWKS empty. 21/22 end-to-end checks pass; the last was my test not following Studio's //project/default redirect.

One note, unrelated to this PR

build-scripts/helpers.ts drifts from the real dokploy implementation in two ways that make local validation misleading: generatePassword includes !@#$%^&* (production is lowercase alphanumeric), and generateJwt drops string payloads so every ${jwt:secret:payload} resolves to {}. Happy to open a separate PR if useful.

🤖 Generated with Claude Code

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 6, 2026
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>
@whollacsek
whollacsek force-pushed the fix/supabase-new-api-keys branch from 0940d4d to f7e7a55 Compare August 6, 2026 19:23
…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>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
templates ✅ Ready (View Log) Visit Preview c5c1aa7

@Siumauricio

Copy link
Copy Markdown
Contributor

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, maintainerCanModify was on): c5c1aa7.

Bug found: dokploy's hash helper emits characters, not bytes

Production dokploy's generateHash(length) is randomBytes(ceil(length/2)).toString("hex").substring(0, length) — it returns exactly length hex characters. The repo's build-scripts/helpers.ts mirror returns randomBytes(length).toString("hex") = 2*length chars — one more instance of the mirror drift you already flagged for generatePassword/generateJwt.

So on a real deploy:

  • realtime_db_enc_key = "${hash:8}" produced an 8-char REALTIME_DB_ENC_KEY. Upstream requires exactly 16 characters (AES-128; .env.example says "generate with openssl rand -hex 8"), and the realtime container crash-looped (Restarting (1)), with /realtime/v1/websocket returning 503 name resolution failed through Kong. Bumped to ${hash:16} → 16 chars, realtime healthy.
  • ${hash:11}_${hash:4} produced sb_publishable_<11>_<4> instead of the intended sb_<role>_<22>_<8> shape. Bumped to ${hash:22}_${hash:8}.

Verification (after the fix, fresh deploy)

All 11 containers healthy, including realtime. Generated env checked via API: ANON_KEY/SERVICE_ROLE_KEY are proper HS256 JWTs with the right role claims, *_ASYMMETRIC reuse the exact same tokens (generated once in [variables]), keys shaped sb_publishable_<22>_<8> / sb_secret_<22>_<8>, REALTIME_DB_ENC_KEY 16 chars.

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.

@Siumauricio
Siumauricio merged commit 7b4af4c into Dokploy:canary Aug 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants