name: supabase-edge-function-jwt-debug
description: Debug and fix JWT verification in Supabase Edge Functions on self-hosted VPS where Kong interferes with GoTrue tokens
triggers:
- "401 Unauthorized in manage-users edge function"
- "PGRST301 JWSError Invalid number of parts"
- "Kong rejecting user tokens from GoTrue"
- "Edge function auth verification failing via /auth/v1/user"
- "GoTrue JWT tokens with 2 dots instead of 3"
- "Edge function works when called directly but returns 401 via Kong"
- "Supabase Cloud edge function cannot validate VPS GoTrue tokens"
- "super_admin JWT returns 401 even with valid signature"
Supabase Edge Function JWT Debug (Self-hosted VPS)
The Problem
On a self-hosted Supabase VPS (deploy-vps), an Edge Function like manage-users returns 401 even when the user is legitimately logged in. The root cause is Kong intercepting and modifying JWT tokens when Edge Functions call /auth/v1/user through Kong, and PostgREST rejecting raw service keys instead of JWTs.
Common Failure Chain
`
Edge Function (user token)
β Kong /auth/v1/user β GoTrue
β GoTrue returns token with 2 dots (signature is raw bytes, not base64)
β Kong's jwt_verifier strips/transforms the token
β Edge Function gets malformed token back
β verifyTokenInline fails β 401
OR:
Edge Function (uses raw SERVICE_ROLE_KEY for PostgREST)
β PostgREST sees "kETxz0...UKI=" as 1 part (not 3-dot JWT)
β PGRST301: JWSError Invalid number of parts
`
The Fix: Inline Web Crypto JWT Verification
The solution is to verify JWTs directly inside the Edge Function using Deno's Web Crypto API, bypassing Kong for auth decisions. Steps:
1. Use JWT_SECRET env var (NOT SERVICE_ROLE_KEY) for JWT verification
CRITICAL: The service role key (SERVICE_ROLE_KEY) is an RSA private key in PKCS#8 format β it's used for RS256 signing by OTHER Supabase services, NOT for verifying GoTrue user tokens. User tokens are signed by GoTrue using JWT_SECRET (also called GOTRUE_JWT_SECRET) with HS256 (HMAC-SHA256).
Using SERVICE_ROLE_KEY for JWT verification will ALWAYS fail β it's the wrong algorithm and key type.
`typescript
// CORRECT β HS256 HMAC key from JWT_SECRET
const JWT_SECRET = Deno.env.get("JWT_SECRET")!; // The GoTrue signing key
// WRONG β RSA key (cannot verify HS256 tokens)
// const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
`
2. Implement inline JWT verification
`typescript
function base64UrlDecode(str: string): Uint8Array {
let s = str.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const binary = atob(s);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function verifyTokenInline(token: string): Promise<{id: string; email?: string} | null> {
try {
// JWT format: header.payload.signature
const lastDot = token.lastIndexOf('.');
const sigB64 = token.substring(lastDot + 1);
const headerPayload = token.substring(0, lastDot);
const lastDotHP = headerPayload.lastIndexOf('.');
const headerB64 = headerPayload.substring(0, lastDotHP);
const payloadB64 = headerPayload.substring(lastDotHP + 1);
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
if (header.alg !== 'HS256') return null;
// CRITICAL: use JWT_SECRET (HS256 HMAC key), NOT SERVICE_ROLE_KEY (RSA)
const key = new TextEncoder().encode(JWT_SECRET);
const signingInputBytes = new TextEncoder().encode(${headerB64}.${payloadB64});
const sigBytes = base64UrlDecode(sigB64);
const keyObj = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const valid = await crypto.subtle.verify('HMAC', keyObj, sigBytes, signingInputBytes);
if (!valid) return null;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
if (!payload.sub) return null;
return { id: payload.sub, email: payload.email };
} catch {
return null;
}
}
`
3. Use service role key for PostgREST calls (bypasses RLS) β different from JWT verification
`typescript
// JWT verification uses JWT_SECRET (HMAC key for HS256)
// PostgREST calls use SERVICE_ROLE_KEY (raw key, bypasses RLS)
async function dbFetch(endpoint: string, options: RequestInit = {}) {
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const headers: Record
"apikey": SERVICE_ROLE_KEY,
"Authorization": Bearer ${SERVICE_ROLE_KEY},
"Content-Type": "application/json",
...(options.headers as Record
};
const url = ${SUPABASE_URL}/rest/v1${endpoint};
const res = await fetch(url, { ...options, headers });
const text = await res.text();
let data: any;
try { data = JSON.parse(text); } catch { data = text; }
if (!res.ok) throw new Error(${res.status}: ${text});
return data;
}
`
4. Verify the JWT_SECRET mathematically before patching code
`python
import hmac, hashlib, base64, json
token = "eyJ..." # user's access_token
jwt_secret = "kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI=" # JWT_SECRET from .env
parts = token.split('.')
header_b64, payload_b64, sig_b64 = parts[0], parts[1], parts[2]
def base64url_decode(s):
s = s.replace('-', '+').replace('_', '/')
while len(s) % 4: s += '='
return base64.b64decode(s)
signing_input = f"{header_b64}.{payload_b64}"
expected_sig = base64url_decode(sig_b64)
computed_sig = hmac.new(jwt_secret.encode(), signing_input.encode(), hashlib.sha256).digest()
print("MATCH:", hmac.compare_digest(expected_sig, computed_sig))
`
Kong as Blocker: 401 Before Reaching the Edge Function
If Kong returns 401 for ALL requests to /functions/v1/* even before the edge function is called, the problem is at the Kong/nginx layer β NOT in the edge function.
Check if Kong is the blocker:
`bash
Direct call to functions-proxy (bypass Kong entirely)
docker exec deploy-vps-functions-proxy wget -O- http://localhost:9000/manage-users 2>&1
If this returns 401, Kong is blocking
If it returns "No authorization", the edge function is reachable (correct behavior)
`
Kong JWT plugin symptoms:
- - Kong log shows
auth-jwtor JWT validation errors - - Direct calls to edge-runtime work but Kong routes don't
- - Kong is rejecting tokens at the nginx level before proxying
- - Tokens from VPS GoTrue will NOT be accepted by Cloud edge functions
- - Full migration requires moving auth too (create accounts in Cloud)
- - OR keep the edge function on the VPS and debug Kong instead
- - Normal JWT:
header.payload.signature_base64(3 parts) - - GoTrue VPS JWT:
header.payload.raw_bytes(2 dots, signature is not valid base64) - -
/docker/deploy-vps/β other applications (ComercialRS, DocCorretor, n8n, Chatwoot) are separate - - Kong config β modifying jwt_verifier or upstream configs can break other services
- - Auth container β GoTrue is configured correctly, the issue was always in how Edge Function called it
- - Edge Function:
/opt/rochasales/supabase/functions/manage-users/index.ts - - Environment:
/docker/deploy-vps/.env(containsJWT_SECRET,PGRST_JWT_SECRET,SERVICE_ROLE_KEY) - - Source code:
/opt/rochasales/src/ - - Tokens from local GoTrue verify correctly via
verifyTokenInline - -
docker logs deploy-vps-functions-1 --tail 20shows successful authentication - - Admin panel pages (Ferramentas, UsuΓ‘rios, Cargos) load without 401 errors
- -
curl -X POST -d "grant_type=..."fails withunsupported_grant_typeorCould not read password grant params - - Browser-based login with GET + query string parameters works
- -
SERVICE_ROLE_KEYdefinition in the file β keep it fordbFetchPostgREST calls - - Kong configuration β
post_actionis a Kong-level behavior, not fixable from edge function - - Other containers β only restart
deploy-vps-functions-1, never the full compose stack
Solutions (in order of invasiveness):
1. Don't restart Kong β affects ALL VPS apps (ComercialRS, DocCorretor, Chatwoot)
2. Deploy edge function to Supabase Cloud β bypasses Kong entirely (see below)
3. Investigate kong.yml β check for jwt_verifier plugin on the functions service route
Migrating Edge Function to Supabase Cloud (Bypassing Kong)
If Kong is the blocker and restarting it is not an option:
Deploy to Supabase Cloud:
`bash
supabase link --project-ref dauftiqcvgaydddoxqhh --password '
mkdir -p supabase/functions/manage-users
copy index.ts to supabase/functions/manage-users/index.ts
cd supabase/functions && supabase functions deploy manage-users --no-verify-jwt
`
The Cross-Authentication Problem:
If the local VPS GoTrue issues tokens with one JWT_SECRET and the Cloud GoTrue uses a different one:
If keeping auth on VPS but moving function to Cloud:
The Cloud edge function would need the VPS JWT_SECRET as an env var β non-standard but technically possible.
`typescript
async function dbFetch(endpoint: string, options: RequestInit = {}) {
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const headers: Record
"apikey": SERVICE_ROLE_KEY,
"Authorization": Bearer ${SERVICE_ROLE_KEY},
"Content-Type": "application/json",
...(options.headers as Record
};
// Inside deploy-vps network, use http://kong:8000/rest/v1
// From outside, use external URL + SERVICE_ROLE_KEY
const url = ${SUPABASE_URL}/rest/v1${endpoint};
const res = await fetch(url, { ...options, headers });
const text = await res.text();
let data: any;
try { data = JSON.parse(text); } catch { data = text; }
if (!res.ok) throw new Error(${res.status}: ${text});
return data;
}
`
Case 6: Expired JWT Token Returns 401 (Even With Valid Signature)
Root Cause
The JWT token is cryptographically valid (signature verifies correctly with JWT_SECRET), but the token has expired (exp claim is in the past). The verifyTokenInline function checks expiration:
`typescript
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
`
When verifyTokenInline returns null due to expiration, the function returns 401 Unauthorized β before any admin role check.
How to Identify
Decode the token in the browser console:
`javascript
// Get token from localStorage
const token = JSON.parse(localStorage.getItem('sb-rochasalesseguros-auth-token') || '{}')?.access_token;
// Decode payload (base64url, no signature verification needed to see exp)
const parts = token.split('.');
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
console.log('exp:', new Date(payload.exp * 1000)); // Expiration date
console.log('iat:', new Date(payload.iat * 1000)); // Issued at
console.log('now:', new Date()); // Current time
console.log('expired:', payload.exp * 1000 < Date.now());
`
Or in Python:
`python
import base64, json, time
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
parts = token.split('.')
payload = json.loads(base64.b64decode(parts[1].replace('-', '+').replace('_', '/')))
print("exp:", datetime.fromtimestamp(payload['exp']))
print("iat:", datetime.fromtimestamp(payload['iat']))
print("expired:", payload['exp'] < time.time())
`
Fix
User must logout and login again to get a fresh token with updated exp claim.
No code changes needed β this is expected Supabase behavior. JWTs have a 1-hour default expiration (GOTRUE_JWT_EXP=3600).
Verify Mathematically
If you have the JWT_SECRET and want to confirm signature validity:
`python
import hmac, hashlib, base64
token = "eyJ..." # from browser localStorage
jwt_secret = "kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI=" # INCLUDE trailing = !
def base64url_decode(s):
s = s.replace('-', '+').replace('_', '/')
while len(s) % 4: s += '='
return base64.b64decode(s)
parts = token.split('.')
header_b64, payload_b64, sig_b64 = parts[0], parts[1], parts[2]
signing_input = f"{header_b64}.{payload_b64}".encode()
expected_sig = base64url_decode(sig_b64)
computed_sig = hmac.new(jwt_secret.encode(), signing_input, hashlib.sha256).digest()
print("β SIGNATURE VALID" if hmac.compare_digest(expected_sig, computed_sig) else "β SIGNATURE INVALID")
print("Expired:", (json.loads(base64url_decode(payload_b64))['exp']) < time.time())
`
Key Discovery: GoTrue JWT Format
GoTrue (v0.x - v1.x) on the VPS has a quirk where HMAC-SHA256 signed JWTs serialize the signature as raw binary bytes (not base64-encoded). This means:
This is why jwt.decode() or online JWT verifiers fail β the signature portion isn't valid base64.
Case 5: Missing sub Field in super_admin JWT β 401 Unauthorized
Symptom
Edge function manage-users returns 401 Unauthorized even with a valid JWT signature and valid super_admin role. The verifyTokenInline function returns null even though the HMAC signature is correct.
Root Cause
The verifyTokenInline function requires payload.sub to be present:
`typescript
if (!payload.sub) return null; // Blocks super_admin tokens without sub
`
Service role / super_admin JWTs issued by GoTrue often lack the sub field:
`json
{
"iss": "supabase",
"ref": "rochasales",
"role": "supabase_admin",
"iat": 1780514396,
"exp": 1780543196,
"is_super_admin": true,
"email": "service_role@rochasales"
// NO "sub" field!
}
`
When verifyTokenInline returns null, the function immediately returns 401 Unauthorized β before any admin role check or is_super_admin logic executes.
Verification
`python
import jwt
token = "your_super_admin_token"
decoded = jwt.decode(token, options={"verify_signature": False})
print("Has sub:", "sub" in decoded) # False for super_admin tokens
`
Fix Applied
Change the sub requirement to allow is_super_admin tokens:
`typescript
// BEFORE (blocks super_admin tokens without sub):
if (!payload.sub) return null;
// AFTER (allows is_super_admin tokens even without sub):
if (!payload.sub && !payload.is_super_admin) return null;
`
Note: Service role tokens bypass normal auth anyway, so the sub requirement is overly strict for is_super_admin: true tokens.
Debugging Commands
`bash
Test direct call to functions-proxy (bypass Kong) with valid JWT
python3 << 'PYEOF'
import jwt, time, urllib.request, json
secret = 'YOUR_JWT_SECRET'
payload = {
'iss': 'supabase', 'ref': 'rochasales', 'role': 'supabase_admin',
'iat': int(time.time()), 'exp': int(time.time()) + 28800,
'is_super_admin': True, 'email': 'service_role@rochasales',
'sub': 'EXISTING_ADMIN_UUID' # Add sub for test
}
token = jwt.encode(payload, secret, algorithm='HS256')
data = json.dumps({"action": "list"}).encode()
req = urllib.request.Request(
"http://VPS_IP:8000/functions/v1/manage-users",
data=data,
headers={
"Authorization": f"Bearer {token}",
"apikey": "ANON_KEY",
"Content-Type": "application/json"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(f"Status: {resp.status}")
PYEOF
Get container logs (edge-runtime has no stdout/stderr by default)
docker logs deploy-vps-functions-1 --since 10m 2>&1
Check what JWT_SECRET is in the container
docker exec deploy-vps-functions-1 env | grep JWT
`
Shell Extraction Tip: JWT_SECRET Trailing = Gotcha
When extracting JWT_SECRET from container env vars via shell, always use printenv instead of cut -d= -f2:
`bash
WRONG β strips trailing "=" from base64 strings
docker exec deploy-vps-functions-1 env | grep JWT_SECRET | cut -d= -f2
Output: kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI (missing trailing =)
CORRECT β preserves full value including trailing "="
docker exec deploy-vps-functions-1 printenv JWT_SECRET
Output: kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI=
`
The JWT_SECRET is a base64-encoded string that often ends with = or == padding. Using cut -d= -f2 interprets = as the delimiter and strips the padding, causing HMAC verification to fail with a signature mismatch error even though the token and secret are correct.
Verification Steps
1. Deploy: docker restart deploy-vps-functions-1
2. Wait ~15-20s for container restart
3. Test: login β admin panel β create user
4. If still 401: check docker logs deploy-vps-functions-1 --tail 50
Case 7: functions-proxy is a Standalone Container (Not in docker-compose)
Symptom
Edge function returns 502 Bad Gateway. Kong logs show it's proxying to functions-proxy but getting no response. Restarting the functions container doesn't help.
Root Cause
The functions-proxy nginx container is NOT managed by docker-compose.yml in /docker/deploy-vps/. It was started manually with docker run and is completely independent. It can go stale while the rest of the stack is healthy.
How to Identify
`bash
This returns nothing (not in compose):
docker compose -f /docker/deploy-vps/docker-compose.yml ps | grep proxy
But this shows it running:
docker ps | grep functions-proxy
deploy-vps-functions-proxy functions-proxy
`
Fix
`bash
Restart the standalone proxy
docker restart deploy-vps-functions-proxy
If that doesn't work, recreate it:
docker rm -f deploy-vps-functions-proxy
docker run -d --name deploy-vps-functions-proxy \
--network deploy-vps_default \
-p 9001:9000 \
nginx:alpine \
sh -c "cat > /etc/nginx/conf.d/default.conf << 'EOF'
server { listen 9000; location / { proxy_pass http://deploy-vps-functions-1:9000; } }
EOF
nginx -g 'daemon off;'"
`
Case 8: dbFetch Doubles /auth/v1 Path β 404 on Auth Endpoints
Symptom
Edge function manage-users returns 500 with error 404: {} when calling dbFetch("/auth/v1/admin/users"). The URL becomes http://kong:8000/rest/v1/auth/v1/admin/users (doubled path).
Root Cause
The dbFetch function always prepends /rest/v1 to the endpoint:
`typescript
const url = ${SUPABASE_URL}/rest/v1${endpoint};
// endpoint = "/auth/v1/admin/users"
// url = "http://kong:8000/rest/v1/auth/v1/admin/users" β WRONG
`
Fix
Modify dbFetch to detect /auth/ endpoints and route them correctly:
`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 unchanged
}
`
Important: Use AUTH_URL = "http://kong:8000" (Kong on port 8000), NOT localhost:9999 (auth container port 9999 is only for direct container-to-container communication and is not accessible from the functions network).
Case 9: SERVICE_ROLE_KEY JWT Causes PostgREST 403 with super_admin Role
Symptom
Edge function returns 500 with dbFetch failed, msg: Error: 403: {"code":"42501","message":"permission denied to set role \"supabase_admin\""}. The SERVICE_ROLE_KEY JWT has role: "supabase_admin" but PostgREST rejects it.
Root Cause
The PostgreSQL role supabase_admin does NOT have bypassrls = true. PostgREST tries to SET ROLE supabase_admin and fails because that role is not configured for JWT-based login. The correct role is service_role which has bypassrls = true.
Generate a Valid SERVICE_ROLE_KEY
`python
import jwt, time
with open('/docker/deploy-vps/.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': 'rochasales',
'role': 'service_role', # NOT "supabase_admin"
'iat': int(time.time()),
'exp': 2138836800, # ~2037
'is_super_admin': False,
'email': 'service_role@rochasales'
},
jwt_secret,
algorithm='HS256'
)
print(svc_key) # ~343 chars
`
Update .env and Restart
`bash
Update .env with new SERVICE_ROLE_KEY
python3 << 'PYEOF'
... (run the code above to generate, then update the file)
PYEOF
Restart functions to pick up new .env
cd /docker/deploy-vps && docker compose up -d --force-recreate functions
`
Case 10: Token Gets Past Auth But 403 β isCallerAdmin Failing
Symptom
JWT verifies correctly (no 401), but function returns 403 Forbidden. The isCallerAdmin function returns false even though the user has role: "admin" in user_roles.
Root Cause
The dbFetch call inside isCallerAdmin to /rest/v1/user_roles?user_id=eq.{id}&role=eq.admin&select=role&limit=1 is failing (returning 404, 403, or empty array). Since the admins full access policy allows SELECT for all roles, the issue is the SERVICE_ROLE_KEY JWT being used to query PostgREST internally.
Debug Steps
Add temporary debug to isCallerAdmin:
`typescript
// In isCallerAdmin catch block:
catch (e) { return { error: String(e) }; }
// In handleManageUsers before the hasAdmin check:
if (typeof hasAdmin === 'object' && hasAdmin.error) {
return new Response(JSON.stringify({ error: "isCallerAdmin failed", detail: hasAdmin }), {
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
`
Root Cause Chain for This Session
1. SERVICE_ROLE_KEY JWT was expired β dbFetch returned 403
2. Even after fixing the JWT, header format was wrong β PostgREST returned 403
3. After fixing header format, role: "supabase_admin" was rejected β PostgREST returned 403
4. After fixing to role: "service_role", the URL was doubled β dbFetch returned 404
5. After fixing the URL routing, everything worked
What NOT to Touch
Files Involved
Case 4: HMAC vs RSA Key Mismatch in verifyTokenInline
Symptom
Edge function manage-users returns 401 for all authenticated requests, even with valid tokens from local GoTrue. The verifyTokenInline function always returns null. Browser-based login works; server-side tools (curl, wget) fail.
Root Cause
The verifyTokenInline function used SERVICE_ROLE_KEY (RSA public key in PKCS#8 format) to verify JWT signatures. However, GoTrue issues tokens signed with JWT_SECRET using HMAC-SHA256 (HS256). These are incompatible algorithms β HMAC verification with an RSA key always fails silently, returning null.
Fix Applied
`typescript
// BEFORE (WRONG): RSA key cannot verify HS256 tokens
const key = new TextEncoder().encode(SERVICE_ROLE_KEY);
// AFTER (CORRECT): JWT_SECRET is the HMAC signing key used by GoTrue
const JWT_SECRET = Deno.env.get("JWT_SECRET")!; // e.g. "kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI="
const key = new TextEncoder().encode(JWT_SECRET);
`
The SERVICE_ROLE_KEY should remain defined in the file for other operations (PostgREST calls that bypass RLS), but JWT verification must use the HMAC key.
Verification
After the fix:
Additional Kong Finding: POST Body Stripping
Kong's post_action directive strips POST request bodies before forwarding to GoTrue's /token endpoint. This is why:
GoTrue's token endpoint only accepts GET requests with all params as query string (no body). The browser handles this naturally.
Deploy Procedure
`bash
1. Edit the source file
nano /opt/rochasales/supabase/functions/main/index.ts
2. Copy to container
docker cp /opt/rochasales/supabase/functions/main/index.ts deploy-vps-functions-1:/home/deno/functions/main/index.ts
3. Restart container (fast, no other apps affected)
docker restart deploy-vps-functions-1
4. Verify deployed code
docker exec deploy-vps-functions-1 grep 'JWT_SECRET\|SERVICE_ROLE_KEY' /home/deno/functions/main/index.ts
5. Check logs after restart
sleep 20 && docker logs deploy-vps-functions-1 --tail 20
`