Supabase PostgREST JWT Authentication — Troubleshooting & Fix
Trigger: CF Worker (or any backend) can’t authenticate to Supabase PostgREST via REST API.
Symptoms: 401 Unauthorized, {"error":"database_error"}, or "Forbidden use of secret API key in browser".
Background — how Supabase PostgREST auth works (new key format)
Since Supabase migrated to opaque API keys (sb_publishable_ / sb_secret_):
| Header | Value | Why |
|---|---|---|
apikey | sb_publishable_... (anon key) | Kong uses this for routing — the sb_secret_ key triggers “browser forbidden” detection from non-Supabase-approved server environments |
Authorization | Bearer <minted-HS256-JWT> | PostgREST reads the role claim from this JWT to grant service_role / authenticated access |
GoTrue signs JWTs with UTF-8 bytes of the secret string — NOT base64-decoded bytes.
Both signing and verification must use new TextEncoder().encode(secret).
The kid header claim is required.
PostgREST uses kid to look up the correct signing key in JWKS. Without it, auth is silently rejected.
The current kid value is stored in SUPABASE_JWT_KID (CF Worker secret / SOPS).
WARNING — ES256 projects (asymmetric signing)
Check your project’s JWT algorithm before implementing the HS256 approach above.
curl -s "https://<project>.supabase.co/auth/v1/.well-known/jwks.json" | jq '.keys[] | {kid, alg}'alg value | Approach | Status |
|---|---|---|
HS256 | Mint JWT with TextEncoder(jwt_secret) + kid (this playbook) | Working |
ES256 | Requires EC private key — not available via any API | Not automatable |
If alg: ES256: The Management API GET /v1/projects/{ref}/postgrest returns an HS256 jwt_secret that is NOT used when the project is in ES256 mode. JWT minting will fail (401) regardless of signing algorithm used.
Workaround for ES256 projects (p24-infra project mwkqmgadqnkkihjdeqsi as of 2026-07-05):
Option A — Dashboard-created Named API Key (recommended):
- Dashboard → Settings → API → Named API Keys → Create new → service_role
- Test:
curl -H "apikey: <VALUE>" "https://<project>.supabase.co/rest/v1/<table>?limit=1" - If returns JSON (not “Invalid API key”), this key is properly registered for PostgREST data-plane access
- Store in SOPS as
SUPABASE_SERVICE_ROLE_KEY
Option B — Direct PostgreSQL connection (no API keys needed):
- Use
SUPABASE_DB_PASSWORD+SUPABASE_DB_HOSTfrom monitoring.env.sops - Works via psql/asyncpg — bypasses Kong/PostgREST entirely
- Suitable for scripts on vps-i1 and n8n workflows
Note: sb_secret_* keys created via Management API (POST /v1/projects/{ref}/api-keys) return {"message":"Invalid API key"} from PostgREST data-plane — they are management-plane tokens only. Verified on this project 2026-07-05.
See issue #2790 for full investigation history.
Confirm the problem
# Does a direct PostgREST call with service_role JWT return 200?
curl -s "https://<project>.supabase.co/rest/v1/<table>?limit=1" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $MINTED_JWT"
# 200 [] = auth OK, table may be empty
# 401 = JWT wrong (wrong bytes, missing kid, wrong secret)
# 403 = RLS blocking (check role claim)Known failure modes
1. JWT signed with base64-decoded bytes (wrong)
Symptom: 401 on every PostgREST call even with a freshly minted JWT.
Cause: Decoding the 88-char base64 JWT secret to 64 bytes before signing. GoTrue does NOT do this — it encodes the raw string as UTF-8.
Fix (TypeScript / jose):
// WRONG — base64-decodes 88-char string to 64 bytes
const key = new Uint8Array(atob(secret).split("").map(c => c.charCodeAt(0)));
// CORRECT — UTF-8 bytes of the raw 88-char base64 string
const key = new TextEncoder().encode(secret.trim());2. Missing kid header in minted JWT
Symptom: 401 even after fixing byte encoding.
Cause: GoTrue includes "kid":"<id>" in all JWT headers. PostgREST looks up the signing key via kid. If absent, PostgREST cannot find the matching key and rejects.
How to find the current kid:
# Decode any GoTrue-issued JWT header (base64url-decode the first segment)
echo "<JWT_FIRST_SEGMENT>" | base64 -d 2>/dev/null | jq .kid
# → "DSJ5e28out4sq86j"Fix:
// Store the kid in env var SUPABASE_JWT_KID
return new SignJWT({ role: "service_role" })
.setProtectedHeader({ alg: "HS256", kid: env.SUPABASE_JWT_KID.trim() })
.setIssuedAt()
.setExpirationTime("10m")
.setIssuer("supabase")
.sign(new TextEncoder().encode(env.SUPABASE_JWT_SECRET.trim()));3. Using sb_secret_ as the apikey header
Symptom: {"message":"Forbidden use of secret API key in browser","hint":"...Delete this secret API key immediately!"} from environments that don’t pass Supabase’s server-detection check.
Cause: Supabase’s Kong gateway blocks sb_secret_ when it detects a non-approved server environment (missing specific server headers). The sb_secret_ key is intended only for Supabase-managed server environments.
Fix: Use sb_publishable_ (the anon key) as apikey. Grant elevated access via the Authorization: Bearer <service_role JWT> header instead.
4. Trailing newline in CF Worker secrets
Symptom: Worker fails (401/500) even though direct tests with the same values succeed.
Cause: Get-Content file | npx wrangler secret put KEY injects a trailing \n into the stored secret. When the Worker uses TextEncoder().encode(secret), it encodes "value\n" instead of "value" — different bytes, different HMAC.
Fix (code-level guard):
const key = new TextEncoder().encode(secret.trim()); // trim() strips \n from pipe injectionFix (secret re-set — use printf to avoid newline):
# Linux / macOS
printf '%s' "$SECRET_VALUE" | npx wrangler secret put SUPABASE_JWT_SECRET
# Windows PowerShell — write with WriteAllText (no trailing newline) then pipe Get-ContentStep-by-step fix procedure
- Identify the failing JWT claim — decode the
AuthorizationBearer token header (base64urlfirst segment) and check forkid,role,iss. - Verify byte encoding — mint a test JWT with
TextEncoder().encode(secret.trim())and test against PostgREST directly. - Check
kid— compare thekidin your minted JWT against GoTrue’s kid (SUPABASE_JWT_KID). - Check
apikeyheader — ensure it’ssb_publishable_, notsb_secret_. - Re-set secrets via wrangler (if CF Worker) — use
printf '%s' "$VAL" | wrangler secret put KEY(notechoorGet-Contentwhich add newlines). - Re-deploy the Worker and test.
CF Worker environment variables required
| Variable | Description | Source |
|---|---|---|
SUPABASE_URL | https://<project>.supabase.co | Supabase project settings |
SUPABASE_ANON_KEY | sb_publishable_... — used as apikey header | Supabase → Settings → API Keys |
SUPABASE_JWT_SECRET | 88-char base64 string — used as-is for HMAC signing | Supabase → Settings → API → JWT Secret |
SUPABASE_JWT_KID | kid claim of the current signing key | Decode any GoTrue JWT header → kid |
Prevention
- Store
SUPABASE_JWT_KIDas a CF Worker secret alongsideSUPABASE_JWT_SECRET— both rotate when Supabase rotates the signing key. - Always
.trim()secrets before encoding to guard against pipe injection. - After a Supabase JWT signing key rotation: update
SUPABASE_JWT_SECRET,SUPABASE_JWT_KID, and redeploy the Worker.
WARNING — ES256 projects (asymmetric signing)
This entire playbook assumes the Supabase project uses HS256 (symmetric, shared secret). Some projects use ES256 (asymmetric, EC private key). The HS256 JWT-minting approach above does not work for ES256 projects.
Check which algorithm your project uses:
curl -s "https://<project>.supabase.co/auth/v1/.well-known/jwks.json" | jq '.keys[] | {kid, alg}'
# alg: "HS256" → follow this playbook normally
# alg: "ES256" → see options belowVerified 2026-07-05: Supabase project mwkqmgadqnkkihjdeqsi uses ES256 (kid: 93782525-2f8b-4838-b946-0ce26e0c20c3). The EC private key is not available via any API — JWT minting from SOPS secrets will not work.
ES256 project options:
| Option | Mechanism | Notes |
|---|---|---|
| Option A — Dashboard Named API Key (recommended) | Dashboard → Settings → API Keys → Create (service_role type) | PostgREST accepts these; must be created manually; store result via /role-secret-manager |
| Option B — Direct PostgreSQL | SUPABASE_DB_PASSWORD + postgres connection string | Bypasses PostgREST entirely; suitable for backend-to-backend only |
Note: sb_secret_* keys created via Management API (POST /v1/projects/{ref}/api-keys) are management-plane only — they return {"message":"Invalid API key"} from PostgREST REST data API. Only Dashboard-created Named API Keys work for PostgREST.
SUPABASE_ACCESS_TOKEN gap (verified 2026-07-05): The personal access token (sbp_... prefix) required for the Management API is missing from all p24-infra SOPS files. It must be created at dashboard.supabase.com → Account → Access Tokens, then stored via /role-secret-manager into secrets/administration.env.sops as SUPABASE_ACCESS_TOKEN.
CONFIRMED: sb_secret_* opaque tokens incompatible with raw PostgREST REST calls (verified 2026-07-06)
All three header patterns tested against project mwkqmgadqnkkihjdeqsi returned 401:
| Pattern | apikey | Authorization | Result |
|---|---|---|---|
| A | sb_secret_* | Bearer sb_secret_* | 401 “Forbidden use of secret API key in browser” |
| B | sb_publishable_* | Bearer sb_secret_* | 401 PGRST301 “Expected 3 parts in JWT; got 1” |
| C | sb_secret_* | (none) | 401 “Forbidden use of secret API key in browser” |
All tests included X-Supabase-Api-Version: 2024-01-01 — it does not change either failure mode.
Root cause: sb_secret_* opaque tokens in the new Supabase key system are NOT JWTs. They are designed to be consumed by the Supabase client library (supabase-js / supabase-py), which translates them into the correct internal auth protocol. They cannot be used as raw Bearer tokens against the PostgREST REST endpoint.
⚠️ CORRECTION (2026-08-02) — Pattern A/C root cause was the client’s User-Agent header, not the key format
Investigating radieu/p24-infra#5093’s
infra_operationsaudit-log 401 gap (log_op.py/log_op.sh, seedocs/infra-operations-audit-operations.md) reproduced this exact “Forbidden use of secret API key in browser” error — and then isolated the real trigger. It is the request’sUser-Agentheader, not thesb_secret_*key itself.Reproduction (same project, same current
SUPABASE_SERVICE_KEY/SUPABASE_SERVICE_ROLE_KEYfromsecrets/monitoring.env.sops, both headersapikey: sb_secret_*+Authorization: Bearer sb_secret_*— i.e. exactly Pattern A above):
Client User-Agent sent Result PowerShell Invoke-WebRequest(default)Mozilla/5.0 (compatible; MSIE 9.0; ...)401 “Forbidden use of secret API key in browser” curlwith-A "Mozilla/5.0 ..."Mozilla/5.0 ...401 same error curl(default, no-A)curl/8.x200 (GET) / 201 (POST insert) curlwith a custom non-browser UAp24-infra-log-op/1.0200 log_op.sh(curl, no-Aoverride) run for real on bms-4curl/8.x200 — verified live 2026-08-02 Supabase’s Kong gateway appears to pattern-match
Mozilla(or similar browser fingerprints) inUser-Agentand rejectsb_secret_*keys from anything that looks like a browser — this is a sensible anti-exfiltration control, not a blanket “raw REST is impossible” restriction. The 2026-07-06 test run above was almost certainly done from this same Windows dev machine using PowerShell’sInvoke-RestMethod/Invoke-WebRequest(the CLAUDE.md-mandated shell tool here), whose defaultUser-Agentstring containsMozilla— which explains why all three patterns “confirmed” 401 uniformly, including Pattern A/C which work fine from a genuine server-side client.Practical takeaway:
sb_secret_*keys asapikeyandAuthorization: Bearerheaders work correctly for raw PostgREST REST calls (log_op.py’surllib.requestandlog_op.sh’scurlalready do this and both work today) — as long as the client’sUser-Agentdoes not resemble a browser. Do not useInvoke-WebRequest/Invoke-RestMethodto test or diagnose PostgREST auth issues on this Windows machine without an explicit non-browser-UserAgentoverride, or you will reproduce this false negative. Pattern B (401 PGRST301,apikey: sb_publishable_*+ a raw opaque token as Bearer) is unaffected by this correction — that failure is real: PostgREST expects a JWT inAuthorization, and an opaquesb_secret_*token is not one.The “migrate everything to the Supabase client library / direct Postgres” guidance below is still a perfectly valid option, but it is not required just to make raw PostgREST calls work — a plain
curl/urllib/non-browser HTTP client withsb_secret_*in both headers is sufficient.
Correct pattern for new Supabase key system
Use the Supabase client library — pass sb_secret_* to createClient(), the library handles auth:
// supabase-js
import { createClient } from "@supabase/supabase-js"
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
// SUPABASE_SERVICE_ROLE_KEY = sb_secret_* — library handles auth internally# supabase-py
from supabase import create_client
supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
# SUPABASE_SERVICE_ROLE_KEY = sb_secret_* — library handles auth internallyOR use Direct PostgreSQL (bypasses PostgREST entirely — suitable for all backend operations):
postgresql://postgres.<project-ref>:<SUPABASE_DB_PASSWORD>@aws-0-eu-central-1.pooler.supabase.com:5432/postgres
SUPABASE_DB_PASSWORD is present in secrets/et-operational-platform.env.sops.
Migration checklist for services using raw REST + SUPABASE_SERVICE_ROLE_KEY
- CF Workers using
fetch()against PostgREST → migrate to@supabase/supabase-jsor direct PostgreSQL - n8n HTTP Request nodes calling PostgREST → migrate to Supabase node or PostgreSQL node
- Python scripts using
requests→ migrate tosupabase-pyorpsycopg2/asyncpg
Related playbooks
supabase-service-key-rotation.md— rotating the service role keysupabase-access-token-rotation.md— rotating the management API access tokensops-windows-crlf.md— SOPS encoding issues on Windows