name: supabase-edge-runtime-jwt-debug
description: Debug 401 Unauthorized in Supabase self-hosted Edge Functions — SERVICE_ROLE_KEY vs JWT_SECRET for token verification
Supabase Self-Hosted: Edge Function 401 Debug
Root Cause
In Supabase self-hosted (VPS), Edge Functions use TWO different JWT verification paths:
1. Built-in Supabase JWT verification — controlled by VERIFY_JUT: "false" in docker-compose. When true, GoTrue validates tokens before passing to the function. Our setup has this disabled.
2. Custom inline JWT verification — the function code calls verifyTokenInline() using crypto.subtle.verify() (HMAC-SHA256). This uses a key to verify the signature.
The Bug
`typescript
// ❌ WRONG — SERVICE_ROLE_KEY is Supabase's internal service key
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const key = new TextEncoder().encode(SERVICE_ROLE_KEY);
// ✅ CORRECT — JWT_SECRET is what GoTrue uses to sign user tokens
const JWT_SECRET = Deno.env.get("JWT_SECRET")!;
const key = new TextEncoder().encode(JWT_SECRET);
`
User tokens (from /auth/v1/token?grant_type=password) are signed by GoTrue using JWT_SECRET. The SERVICE_ROLE_KEY is a completely different key (has iss: "supabase", role: "supabase_admin"). Using the wrong key for signature verification causes ALL tokens to fail with 401 Unauthorized.
Debugging Steps
`
1. docker exec deploy-vps-functions-1 env | grep JWT_SECRET → get actual JWT_SECRET
2. docker exec deploy-vps-functions-1 cat /home/deno/functions/main/index.ts | grep -n SERVICE_ROLE_KEY
3. Decode user token: curl -s -X POST 'https://site.com/auth/v1/token?grant_type=password' \
-H 'apikey: ANON_KEY' -d '{"email":"...","password":"..."}' | python3 -c '...'
→ Check: token has NO aud/iss claims, only sub/email/role
4. Decode SERVICE_ROLE_KEY: echo $SERVICE_ROLE_KEY | cut -d. -f2 | base64 -d
→ Has iss: "supabase", role: "supabase_admin", is_super_admin: true
5. Fix: change SERVICE_ROLE_KEY → JWT_SECRET in verifyTokenInline
`
Files to Update
When fixing the SERVICE_ROLE_KEY → JWT_SECRET bug in verifyTokenInline:
- -
/opt/rochasales/supabase/functions/manage-users/main/index.ts(container mount target) - -
/opt/rochasales/supabase/functions/manage-users/index.ts(source copy) - - rochasalesseguros.com.br — uses
/opt/rochasales(THIS project) - - DocCorretor — uses build at port 3010, separate service
- - ComercialRS — separate project
- - Chatwoot — separate
Key Environment Variables (from docker exec deploy-vps-functions-1 env)
| Variable | Purpose |
| --- | --- |
JWT_SECRET | GoTrue signing key — use this to verify user tokens |
SERVICE_ROLE_KEY | Supabase internal admin key — do NOT use for user token verification |
VERIFY_JWT | Set to "false" in our setup — inline verification is used instead |
How to Verify the Fix
`bash
Get fresh token
TOKEN=$(curl -s -X POST 'https://rochasalesseguros.com.br/auth/v1/token?grant_type=password' \
-H 'apikey: ANON_KEY' \
-d '{"email":"user@domain.com","password":"..."}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))')
Call manage-users function with token
curl -s 'https://rochasalesseguros.com.br/functions/v1/manage-users' \
-H "Authorization: Bearer $TOKEN" \
-H 'apikey: ANON_KEY' \
-d '{"action":"list"}'
`
Should return {"data":[...]} not {"error":"Unauthorized"}.
Critical Findings (June 2026)
JWT_SECRET must be exactly 44 bytes — no trailing newline
If .env has JWT_SECRET=abc...\n (with \n), GoTrue and Edge Functions will have DIFFERENT signing keys → 401. Always verify with:
`bash
docker exec deploy-vps-functions-1 sh -c 'echo "${JWT_SECRET}" | wc -c' # must be 44, not 45
docker exec deploy-vps-auth-1 sh -c 'echo "${GOTRUE_JWT_SECRET}" | wc -c' # must be 44, not 45
`
Both containers must show 44, not 45. Fix by removing trailing \n from .env.
SERVICE_ROLE_KEY JWT: use role: "service_role", NOT role: "supabase_admin"
The JWT inside SERVICE_ROLE_KEY must have role: "service_role" (not supabase_admin). PostgREST rejects supabase_admin role with permission denied to set role "supabase_admin". The service_role PostgreSQL role has bypassrls=true which is sufficient for admin operations.
Generate correct SERVICE_ROLE_KEY with PyJWT:
`python
import jwt
with open('.env') as f:
for line in f:
if line.startswith('JWT_SECRET='):
secret = line.split('=',1)[1].strip()
break
svc_key = jwt.encode({
'sub': '00000000-0000-0000-0000-000000000000',
'iss': 'supabase', 'ref': 'rochasales',
'role': 'service_role', # NOT supabase_admin
'iat': 1780674526, 'exp': 2138836800,
'is_super_admin': False,
'email': 'service_role@rochasales'
}, secret, algorithm='HS256')
Update .env: SERVICE_ROLE_KEY=
`
dbFetch routes /auth/* to wrong URL — 404 on admin API calls
The original code does dbFetch("/auth/v1/admin/users") which creates URL http://kong:8000/rest/v1/auth/v1/admin/users — /rest/v1 gets added AND /auth/v1 stays, creating a 404. Fix: route /auth/* calls to a separate base (Kong port 8000, no /rest/v1 prefix):
`typescript
const AUTH_URL = "http://kong:8000";
async function dbFetch(endpoint: string, options: RequestInit = {}) {
const isAuth = endpoint.startsWith("/auth/");
const base = isAuth ? AUTH_URL : SUPABASE_URL; // SUPABASE_URL = "http://kong:8000"
const path = isAuth ? endpoint : /rest/v1${endpoint};
const url = ${base}${path};
// ...
}
`
422 email duplicate from GoTrue throws 500 in edge function
When GoTrue returns 422 (user already exists), the error propagates as HTTP 500. Catch and return 422 properly:
`typescript
try {
createRes = await dbFetch("/auth/v1/admin/users", { method: "POST", body: ... });
} catch (e: any) {
if (e.message && e.message.includes("422")) {
return new Response(JSON.stringify({ error: "A user with this email address has already been registered" }), {
status: 422, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
throw e;
}
`
functions-proxy is NOT in docker-compose — separate container
The nginx proxy for edge functions (deploy-vps-functions-proxy) is a standalone container, not managed by docker-compose. Restart with:
`bash
docker restart deploy-vps-functions-proxy
`
Full recreate:
`bash
docker rm -f deploy-vps-functions-proxy && docker run -d --name deploy-vps-functions-proxy ...
`
Token invalidation after container restart
When containers are recreated (e.g., docker compose up -d --force-recreate functions), user tokens stored in browser localStorage become invalid because the JWT verification context changed. Users must logout and login again. Always warn users to re-authenticate after container-level changes.
broker_links 400: missing display_order column
If GET /rest/v1/broker_links?order=display_order.asc returns 400, add the column:
`sql
ALTER TABLE broker_links ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0;
`
Critical VPS Apps Using Same Supabase
Changes to /opt/rochasales/supabase/ edge-runtime/functions ONLY affect rochasalesseguros.com.br. DocCorretor uses a separate build and .env with different Supabase URL.