If you're running self-hosted Supabase, understanding JWT and session security isn't optional—it's fundamental. A misconfigured JWT secret can expose your entire user base to impersonation attacks. Yet many self-hosting guides gloss over this critical topic, leaving developers with default configurations that work but aren't production-ready.
This guide covers everything you need to know: migrating from symmetric to asymmetric JWT signing, configuring session tokens properly, and implementing security best practices that align with compliance frameworks like SOC2, HIPAA, and PCI-DSS.
Understanding Supabase JWT Architecture
Supabase Auth uses a JWT-based session model where access tokens are short-lived JWTs and refresh tokens are unique strings stored in the database. This architecture enables stateless authentication—your API can validate requests without hitting the auth server on every request.
How Sessions Work
When a user authenticates, Supabase Auth issues two tokens:
- Access Token (JWT): Contains user claims, expires in 1 hour by default
- Refresh Token: A unique string that can be exchanged once for a new token pair
The access token includes critical claims:
{
"iss": "https://your-supabase-url",
"sub": "user-uuid",
"aud": "authenticated",
"exp": 1711451234,
"role": "authenticated",
"email": "[email protected]",
"session_id": "session-uuid",
"aal": "aal1"
}
Why This Matters for Self-Hosting
On Supabase Cloud, JWT configuration is managed for you. When self-hosting, you're responsible for:
- Generating secure JWT secrets
- Configuring signing algorithms
- Managing key rotation
- Ensuring all services share the correct keys
A single misconfiguration can silently break authentication across your entire stack.
Migrating to Asymmetric JWT Keys
Historically, self-hosted Supabase used HS256 (symmetric) signing with a shared JWT_SECRET. While functional, this approach has significant drawbacks:
- The same secret signs and verifies tokens—if leaked, attackers can forge valid tokens
- Key rotation requires coordinated updates across all services
- Difficult to align with security compliance frameworks
Supabase now supports asymmetric signing (RS256, ES256, Ed25519), and migrating is strongly recommended.
Generating Asymmetric Keys
If you followed the standard Docker setup, you can generate new keys using the provided script:
# Navigate to your Supabase directory cd /path/to/supabase # Generate new asymmetric keys ./generate-keys.sh
This outputs several environment variables:
JWT_SIGNING_KEYS: Private and public key pairJWT_METHODS: Indicates asymmetric signing is enabledJWT_JWKS: JSON Web Key Set for verification
Configuring the Auth Service
Update your .env file with the generated values:
# New asymmetric configuration
GOTRUE_JWT_KEYS=${JWT_SIGNING_KEYS}
GOTRUE_JWT_VALID_METHODS=${JWT_METHODS}
# Keep legacy secret for backward compatibility
JWT_SECRET=your-existing-jwt-secret
The GOTRUE_JWT_KEYS variable should contain your key in this format:
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"d": "..."
}
]
}
Updating Verification Services
All services that verify JWTs need access to the JWKS. Update your configuration:
# PostgREST
PGRST_JWT_SECRET=${JWT_JWKS}
# Realtime
REALTIME_JWT_SECRET=${JWT_JWKS}
# Storage
STORAGE_JWT_SECRET=${JWT_JWKS}
The JWKS contains both the new EC public key and the legacy symmetric key, allowing services to verify both old and new tokens during migration.
Migration Timeline
After enabling asymmetric signing:
- Immediately: New sessions receive ES256-signed tokens
- During transition: Services verify both ES256 and HS256 tokens
- After 1 hour+: All active sessions have new tokens (assuming default expiry)
- Optional: Remove legacy
JWT_SECRETfrom JWKS after confirming all tokens have rotated
Plan a maintenance window if you have many active users—regenerating asymmetric keys invalidates all ES256-signed sessions.
Session Token Configuration
Proper session configuration balances security with user experience. Here are the key settings and their implications.
Access Token Expiration
The default expiration of 1 hour works for most applications. Configure this in your auth settings:
GOTRUE_JWT_EXP=3600 # 1 hour in seconds
Setting longer expiration (>1 hour):
- Reduces auth server load
- Increases risk window if tokens are compromised
- May conflict with security compliance requirements
Setting shorter expiration (<5 minutes):
- Increases refresh frequency and server load
- Can cause issues with device clock skew
- Creates difficult-to-debug authentication failures
The Supabase documentation explicitly warns against values below 2 minutes due to clock skew issues.
Refresh Token Behavior
Refresh tokens never expire but can only be used once. Each use generates a new token pair and invalidates the old refresh token. This provides:
- Continuous sessions without re-authentication
- Automatic revocation if tokens are stolen (attacker and user race to refresh)
- Detection of compromised tokens (double-use indicates theft)
Session Validation Best Practices
A common mistake is using getClaims() for authorization decisions. This method only validates the JWT locally—checking signature and expiration—but doesn't verify the session is still active.
// WRONG: Only validates JWT locally
const { data: claims } = await supabase.auth.getClaims()
// CORRECT: Verifies session is still valid with auth server
const { data: { user } } = await supabase.auth.getUser()
The only way to confirm a user hasn't logged out server-side is to call getUser(). Use getClaims() for low-stakes operations where slight staleness is acceptable; use getUser() for sensitive actions.
Server-Side Rendering Security
If you're using SSR frameworks like Next.js, session handling requires extra care.
Moving to PKCE Flow
The implicit flow stores tokens in local storage, which isn't accessible server-side. For SSR, switch to PKCE flow and store tokens in secure HTTP-only cookies:
import { createServerClient } from '@supabase/ssr'
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.delete({ name, ...options })
},
},
}
)
CDN Caching Pitfalls
When refreshing tokens server-side, Supabase sets new tokens via Set-Cookie headers. If your CDN caches these responses, different users can receive each other's session tokens.
Prevent this by setting proper cache headers on authenticated routes:
// Next.js example
export async function GET(request: Request) {
const response = await handleAuthenticatedRequest(request)
response.headers.set('Cache-Control', 'private, no-store')
return response
}
This is non-negotiable for any route that handles authentication.
Security Hardening Checklist
Beyond JWT configuration, several practices improve your auth security posture.
Always Use TLS
Run Supabase Auth behind a TLS-capable proxy. Never expose the auth service directly:
# nginx example
server {
listen 443 ssl;
server_name auth.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:9999;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
For custom domain configuration, see the custom domains setup guide.
Validate at Database Level
JWTs are verified at the API gateway (Kong), but you can add database-level validation for sensitive operations:
-- Check session is still active
CREATE OR REPLACE FUNCTION check_session_active()
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM auth.sessions
WHERE id = (auth.jwt() ->> 'session_id')::uuid
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Use this in RLS policies for high-security tables. Learn more in the Row Level Security guide.
Keep Auth Updated
Supabase Auth (GoTrue) receives regular security updates. Self-hosting means you're responsible for applying them:
# Check for updates docker pull supabase/gotrue:latest # Compare versions docker inspect supabase/gotrue:latest | grep -i version
If you're managing multiple instances, tools like Supascale automate version tracking and updates across your deployments.
Audit JWT Claims
Monitor for anomalies in JWT usage. Log and alert on:
- Multiple sessions from the same user across distant geolocations
- Tokens with manipulated claims (role escalation attempts)
- High-frequency refresh token usage (potential token theft)
The audit logging guide covers setting up comprehensive auth auditing.
Troubleshooting Common Issues
"Invalid JWT" After Key Rotation
If you regenerated keys and users can't authenticate:
- Verify all services have the updated
JWT_JWKS - Check that
JWT_SECRETis still in the JWKS for legacy tokens - Restart all services to pick up new configuration
- Wait for access tokens to expire (1 hour by default)
Clock Skew Errors
JWTs include exp and iat claims that depend on synchronized time. If authentication randomly fails:
# Check server time timedatectl status # Sync with NTP sudo systemctl enable --now systemd-timesyncd
Ensure all services run on servers with NTP synchronization.
Session Not Found
If getUser() returns null but the JWT appears valid:
- The user logged out server-side
- The session was manually revoked
- Session table was cleaned up
Query the sessions table directly:
SELECT * FROM auth.sessions WHERE id = 'session-uuid-from-jwt';
Conclusion
JWT and session security for self-hosted Supabase requires understanding both the theory and the practical configuration. The migration from symmetric to asymmetric signing is particularly important—it significantly improves your security posture and aligns with compliance frameworks.
Key takeaways:
- Migrate to asymmetric JWT signing (ES256 recommended)
- Use 1-hour token expiration unless you have specific requirements
- Always call
getUser()for security-sensitive authorization - Set
Cache-Control: private, no-storeon authenticated SSR routes - Keep your auth service updated
Managing JWT configuration, key rotation, and security updates across multiple Supabase instances gets complex. Supascale handles this automatically—configuring secure defaults, automating backups, and providing a UI for auth settings that would otherwise require manual environment variable management.
