name: supabase-vps-vs-cloud-jwt-mismatch
description: Debug JWT token incompatibility between Supabase VPS (self-hosted) and Supabase Cloud — tokens signed by one environment are rejected by the other
triggers:
- "token unauthorized supabase cloud"
- "JWT_SECRET mismatch supabase"
- "edge function 401 after moving between VPS and Cloud"
- "manage-users unauthorized cloud VPS token"
Supabase VPS vs Cloud JWT Mismatch
The Problem
You have a Supabase instance running on a VPS (self-hosted) and another on Supabase Cloud. When you call a Supabase Cloud Edge Function using an access token issued by the VPS GoTrue auth service, you get {"error":"Unauthorized"}.
`
VPS GoTrue (JWT_SECRET: kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI=)
↓ signs token with VPS JWT_SECRET
access_token
↓ sent to Cloud function
Cloud Edge Runtime verifies with CLOUD's SERVICE_ROLE_KEY (RSA public key)
↓ mismatch → Unauthorized
`
Why This Happens
- - VPS GoTrue signs JWTs using
JWT_SECRET(HMAC-SHA256, symmetric key) - - Supabase Cloud Edge Functions verify JWTs using
SERVICE_ROLE_KEY(RS256, asymmetric RSA) - - These are completely different signing algorithms — you cannot use a VPS token with a Cloud function
- - The function needs to verify against the VPS JWT_SECRET (not Cloud's key)
- - Set
JWT_SECRETas an environment variable in the Cloud function - -
/opt/rochasales/supabase/docker-compose.yml— definesJWT_SECRETenv var - -
/opt/rochasales/.env—JWT_SECRET=kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI= - -
/opt/rochasales/supabase/functions/manage-users/index.ts— custom function that was patched to useJWT_SECRET - -
supabase functions deploy— deploys to Cloud, ignores localJWT_SECRET(uses Cloud's instead)
Diagnostic Test
`python
import jwt
import base64
import hmac
import hashlib
VPS JWT_SECRET (from /opt/rochasales/supabase/docker-compose.yml or .env)
vps_jwt_secret = "kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI="
Decode VPS token (no verification to see payload)
vps_token = "eyJ..."
Decode header and payload without verification
parts = vps_token.split('.')
header = parts[0]
payload = parts[1]
def b64decode_urlsafe(s):
# Add padding
s += '=' * (4 - len(s) % 4)
return base64.urlsafe_b64decode(s)
header_decoded = b64decode_urlsafe(header)
payload_decoded = b64decode_urlsafe(payload)
print(f"Header: {header_decoded}")
print(f"Payload: {payload_decoded}")
The 'alg' field in header tells you the algorithm
If "alg": "HS256" → symmetric (VPS style)
If "alg": "RS256" → asymmetric (Cloud style)
Try verifying with VPS JWT_SECRET (this should work on VPS)
try:
decoded = jwt.decode(vps_token, vps_jwt_secret, algorithms=["HS256"])
print(f"VPS verification SUCCESS: {decoded}")
except Exception as e:
print(f"VPS verification FAILED: {e}")
`
Solutions
Option 1: Migrate EVERYTHING to Cloud (Full Migration)
Move auth, database, AND functions all to Supabase Cloud:
1. Create user accounts in Supabase Cloud (not VPS)
2. Deploy edge functions to Supabase Cloud
3. Point frontend to Supabase Cloud URL
Pro: Single system, no token issues
Con: Requires changing frontend env vars, affects ALL users
Option 2: Keep Auth on VPS, Move Only Specific Functions to Cloud
Only move the function you need to Cloud. For the auth to work:
`bash
supabase functions deploy
--env-file <(echo "JWT_SECRET=kETxz0JNJhJyg5UYxbd8zG6g2v9kXTYZGjY+3v7LUKI=")
`
Caveat: This works for custom edge functions you write, but NOT for built-in Supabase Cloud APIs (like the auth token verification that Supabase does internally).
Option 3: Keep Everything on VPS (Don't Use Cloud)
Stay on VPS self-hosted for everything. Fix the edge-runtime issue on VPS directly.
Option 4: Use a Custom Auth Flow
Instead of relying on Supabase JWT verification:
1. In the edge function, manually parse the token and validate claims
2. Don't use Supabase's built-in verifyJWT() — implement your own verification using the VPS JWT_SECRET
`typescript
// In your edge function
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return new Response(JSON.stringify({ error: 'No authorization' }), { status: 401 });
}
try {
// Decode without verifying (to get claims)
const payload = JSON.parse(atob(token.split('.')[1]));
// Custom verification using VPS JWT_SECRET
const secret = Deno.env.get('JWT_SECRET')!;
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const key = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
);
const valid = await crypto.subtle.verify(
'HMAC-SHA256', key,
b64url_decode(token.split('.')[2]),
new TextEncoder().encode(token.split('.')[0] + '.' + token.split('.')[1])
);
if (!valid) throw new Error('Invalid signature');
// Token is valid, use payload.sub, payload.email, etc.
} catch (e) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
`
Key Finding for Rocha Sales
When manage-users edge function was deployed to Supabase Cloud with the VPS JWT_SECRET set as env var, it still returned Unauthorized. This is because:
1. Cloud Edge Runtime's own verification layer (before your function code runs) checks the token with Cloud's RSA public key
2. Your function code never even gets executed because the Cloud infrastructure rejects the token first
Conclusion: You cannot mix VPS auth tokens with Supabase Cloud functions without also using Supabase Cloud auth. The token verification happens at the infrastructure level, not in your function code.