πŸ“„ SKILL.md

← Vault

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: