JWT and Session Security for Self-Hosted Supabase: A Complete Guide

Configure asymmetric JWT keys, manage sessions securely, and implement token security best practices for self-hosted Supabase instances.

Cover Image for JWT and Session Security for Self-Hosted Supabase: A Complete Guide

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 pair
  • JWT_METHODS: Indicates asymmetric signing is enabled
  • JWT_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:

  1. Immediately: New sessions receive ES256-signed tokens
  2. During transition: Services verify both ES256 and HS256 tokens
  3. After 1 hour+: All active sessions have new tokens (assuming default expiry)
  4. Optional: Remove legacy JWT_SECRET from 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:

  1. Verify all services have the updated JWT_JWKS
  2. Check that JWT_SECRET is still in the JWKS for legacy tokens
  3. Restart all services to pick up new configuration
  4. 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:

  1. The user logged out server-side
  2. The session was manually revoked
  3. 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-store on 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.

Further Reading