name: supabase-self-hosted-jwt-debug
description: Debug 401/403/500 errors in Supabase self-hosted (Docker/VPS) — SERVICE_ROLE_KEY JWT issues, cross-schema FK, edge function dbFetch bugs, GoTrue v1 vs v2 admin endpoints
category: devops
Supabase Self-hosted JWT Debugging
Debug Supabase self-hosted (Docker) when edge functions or REST API return 401/403/500 errors despite valid credentials.
Common Root Causes
1. SERVICE_ROLE_KEY JWT is expired
Symptom: dbFetch returns 403 or 500, direct REST calls work fine.
Check: Decode the JWT at jwt.io — look for exp in the past.
Fix: Generate a new SERVICE_ROLE_KEY with future expiry using PyJWT:
`python
import jwt
with open('/path/to/.env') as f:
for line in f:
if line.startswith('JWT_SECRET='):
jwt_secret = line.split('=',1)[1].strip()
break
svc_key = jwt.encode(
{
'sub': '00000000-0000-0000-0000-000000000000',
'iss': 'supabase',
'ref': 'YOUR_REF',
'role': 'service_role', # NOT 'supabase_admin' — PostgREST rejects it
'iat': 1780674526,
'exp': 2138836800, # ~2037
'is_super_admin': False,
'email': 'service_role@YOUR_DOMAIN'
},
jwt_secret,
algorithm='HS256'
)
`
Update .env, restart functions container: docker compose up -d --force-recreate functions
Why NOT supabase_admin? PostgREST says permission denied to set role "supabase_admin" — the anon key cannot escalate to supabase_admin. Use role: "service_role" which has bypassrls=true in pg_roles.
2. dbFetch double-prepending /rest/v1 to /auth/v1 paths
Symptom: Edge function returns 404: /rest/v1/auth/v1/admin/users
Cause: dbFetch always prepends /rest/v1, but auth endpoints already start with /auth/v1
Fix: Update dbFetch to detect auth routes:
`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;
const path = isAuth ? endpoint : /rest/v1${endpoint};
const url = ${base}${path};
// ... rest of fetch
}
`
3. JWT_SECRET has trailing newline in .env
Symptom: Tokens signed with JWT_SECRET from .env fail verification.
Check: len(jwt_secret) — should be 44 bytes (base64 of 32 bytes), no \n.
Fix: Remove trailing \n from JWT_SECRET in .env.
4. 422 (email duplicate) returns as 500 from edge function
Symptom: GoTrue returns 422 but edge function catches it and returns 500.
Fix: Catch 422 specifically in create handler:
`typescript
try {
createRes = await dbFetch("/auth/v1/admin/users", { method: "POST", ... });
} catch (e: any) {
if (e.message && e.message.includes("422")) {
return new Response(JSON.stringify({ error: "Email already registered" }), {
status: 422, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
throw e;
}
`
Cross-Schema FK: profiles.user_id → auth.users
PostgREST cannot resolve FK from public.profiles to auth.users (different schemas).
Symptom: select=*,user:user_id(email) returns 400.
Fix: Create a view:
`sql
CREATE OR REPLACE VIEW public.user_profiles AS
SELECT p.*, u.email
FROM public.profiles p
LEFT JOIN auth.users u ON u.id = p.user_id;
GRANT SELECT ON public.user_profiles TO anon;
GRANT SELECT ON public.user_profiles TO authenticated;
`
Then query user_profiles instead of profiles.
Key Container Names (VPS context)
- -
deploy-vps-functions-1— Edge Functions - -
deploy-vps-kong-1— API Gateway - -
deploy-vps-db-1— PostgreSQL - -
deploy-vps-auth-1— GoTrue auth - -
deploy-vps-studio-1— Supabase Studio (port 3000)
Verify SERVICE_ROLE_KEY works
`bash
Inside container
curl -s http://localhost:8000/rest/v1/user_roles?user_id=eq.USER_ID -H "apikey: $SERVICE_ROLE_KEY"
Should return data, not 403
`
GoTrue version check
GoTrue v1 admin endpoint: POST /auth/v1/admin/token → 404 (doesn't exist in v1)
GoTrue v2 admin endpoint: POST /auth/v1/admin/token → works
Check version: docker exec deploy-vps-auth-1 env | grep GOTRUE_VERSION