📄 SKILL.md

← Vault

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)

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