JWT Tokens and Session Management
What is a JWT?
A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. It's the standard mechanism for carrying authentication state in Supabase.
JWT Structure
A JWT consists of three parts separated by dots:
xxxxx.yyyyy.zzzzz
│ │ │
│ │ └── Signature
│ └── Payload (claims)
└── Header
Example JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A28Ms
Header
Contains metadata about the token:
{
"alg": "HS256", // Signing algorithm
"typ": "JWT" // Token type
}
Base64URL encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload (Claims)
Contains the actual data:
{
"sub": "uuid-of-user",
"email": "user@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": 1704067200,
"iat": 1704063600,
"app_metadata": {
"provider": "email"
},
"user_metadata": {
"name": "Alice"
}
}
Signature
Verifies the token hasn't been tampered with:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
Supabase JWT Claims
A Supabase JWT contains specific claims:
{
// Standard claims
"sub": "user-uuid", // Subject (user ID)
"aud": "authenticated", // Audience
"exp": 1704067200, // Expiration (Unix timestamp)
"iat": 1704063600, // Issued at
"iss": "https://xxx.supabase.co/auth/v1", // Issuer
// Supabase-specific
"role": "authenticated", // PostgreSQL role
"email": "user@example.com",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {
"full_name": "Alice Smith"
},
"session_id": "session-uuid"
}
How JWTs Work in Supabase
Authentication Flow
┌─────────────┐ ┌─────────────┐
│ Client │ 1. Login (email/password) │ GoTrue │
│ │ ─────────────────────────────│ │
│ │ │ │
│ │ 2. Validate credentials │ │
│ │ │ │
│ │ 3. Generate JWT │ │
│ │ ←─────────────────────────── │ │
│ │ │ │
│ Store JWT │ │ │
│ in memory │ │ │
└─────────────┘ └─────────────┘
API Request Flow
┌─────────────┐ ┌─────────────┐
│ Client │ GET /rest/v1/posts │ PostgREST │
│ │ Authorization: Bearer JWT │ │
│ │ ─────────────────────────────│ │
│ │ │ │
│ │ 1. Verify JWT signature │ │
│ │ 2. Check expiration │ │
│ │ 3. Extract claims │ │
│ │ 4. Set Postgres session │ │
│ │ 5. Execute query │ │
│ │ 6. RLS uses auth.uid() │ │
│ │ │ │
│ │ Response with data │ │
│ │ ←─────────────────────────── │ │
└─────────────┘ └─────────────┘
Access vs Refresh Tokens
Supabase uses a two-token system:
Access Token
- Purpose: Authorize API requests
- Lifespan: Short (default: 1 hour)
- Storage: Memory (not persistent)
- Contents: User claims, roles
- Sent: With every API request
Refresh Token
- Purpose: Get new access tokens
- Lifespan: Long (default: 1 week)
- Storage: Can be persistent (localStorage, cookies)
- Contents: Token identifier
- Sent: Only to refresh endpoint
Why Two Tokens?
Security vs. Usability Trade-off
Short-lived access token:
✓ If stolen, limited damage (expires soon)
✓ Can't be revoked (but dies quickly)
✗ Frequent authentication needed
Long-lived refresh token:
✓ Convenient (stays logged in)
✓ Can be revoked server-side
✗ If stolen, attacker can get new access tokens
The two-token system provides:
- Convenience (long sessions)
- Security (short-lived API access)
- Revocability (invalidate refresh tokens)
Token Refresh Process
┌─────────────────────────────────────────────────────────┐
│ Token Lifecycle │
│ │
│ Login │
│ │ │
│ ▼ │
│ Access Token ────────────────────────────────────┐ │
│ (1 hour) │ │
│ │ │ │
│ │ Expires │ │
│ ▼ │ │
│ Use Refresh Token ──→ Get new Access Token ─────┘ │
│ │
│ Refresh Token ─────────────────────────────────────┐ │
│ (1 week) │ │
│ │ │ │
│ │ Expires │ │
│ ▼ │ │
│ Require new login │ │
│ │
└─────────────────────────────────────────────────────────┘
The Supabase SDK handles refresh automatically:
// SDK automatically refreshes tokens
const supabase = createClient(url, anonKey)
// You don't need to handle expiration manually
// SDK detects expiring tokens and refreshes them
Session Management
Session Storage Options
// Browser memory (safest, clears on page close)
createClient(url, key, {
auth: {
persistSession: false
}
})
// localStorage (persists, XSS vulnerable)
createClient(url, key, {
auth: {
persistSession: true,
storage: localStorage
}
})
// Custom storage (cookies, secure storage, etc.)
createClient(url, key, {
auth: {
persistSession: true,
storage: {
getItem: (key) => { /* custom get */ },
setItem: (key, value) => { /* custom set */ },
removeItem: (key) => { /* custom remove */ }
}
}
})
Session Events
React to authentication changes:
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
switch (event) {
case 'SIGNED_IN':
console.log('User signed in:', session.user)
break
case 'SIGNED_OUT':
console.log('User signed out')
break
case 'TOKEN_REFRESHED':
console.log('Token was refreshed')
break
case 'USER_UPDATED':
console.log('User metadata updated')
break
}
}
)
// Cleanup when done
subscription.unsubscribe()
JWT Security Considerations
What JWTs Are NOT
Common Misconception: "JWTs are encrypted"
WRONG! JWTs are only:
- Encoded (base64) - Anyone can decode
- Signed - Tamper-evident, not secret
The payload is readable by anyone with the token!
What This Means
// Anyone can decode a JWT
const token = "eyJhbGci...";
const payload = JSON.parse(atob(token.split('.')[1]));
console.log(payload);
// {
// sub: "user-123",
// email: "alice@example.com", // Visible!
// ...
// }
Never put secrets in JWT claims!
Token Theft Risks
If an attacker gets your JWT:
- They can impersonate you until it expires
- Access tokens: Limited time (1 hour)
- Refresh tokens: Longer risk (1 week)
Mitigation Strategies
- Use HTTPS always: Prevent network interception
- Short access token expiry: Limit damage window
- Secure storage: Avoid localStorage for sensitive apps
- Refresh token rotation: New refresh token on each use
- Sign out on suspicious activity: Revoke all sessions
Accessing JWT Data
In JavaScript
// Get current session
const { data: { session } } = await supabase.auth.getSession()
if (session) {
console.log('User ID:', session.user.id)
console.log('Email:', session.user.email)
console.log('Metadata:', session.user.user_metadata)
console.log('Access Token:', session.access_token)
}
In RLS Policies
-- Get user ID from JWT
auth.uid() -- Returns the 'sub' claim as UUID
-- Get full JWT as JSON
auth.jwt() -- Returns entire payload
-- Examples
SELECT auth.uid();
-- Returns: 'uuid-of-current-user'
SELECT auth.jwt()->>'email';
-- Returns: 'user@example.com'
SELECT auth.jwt()->'user_metadata'->>'full_name';
-- Returns: 'Alice Smith'
Common RLS Patterns
-- User owns resource
CREATE POLICY "Users own their data"
ON profiles FOR ALL
USING (id = auth.uid());
-- Check custom claim
CREATE POLICY "Premium users only"
ON premium_content FOR SELECT
USING (
(auth.jwt()->'app_metadata'->>'plan')::text = 'premium'
);
-- Check role
CREATE POLICY "Admins only"
ON admin_table FOR ALL
USING (
auth.jwt()->>'role' = 'admin'
);
Custom Claims
Adding Custom Claims
Use database functions or webhooks:
-- Function to add custom claims
CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
claims jsonb;
user_role text;
BEGIN
-- Get user's role from your tables
SELECT role INTO user_role
FROM user_profiles
WHERE id = (event->>'user_id')::uuid;
-- Add to claims
claims := event->'claims';
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
Using Custom Claims in RLS
CREATE POLICY "Role-based access"
ON sensitive_data FOR SELECT
USING (
(auth.jwt()->>'user_role')::text = 'manager'
);
Key Takeaways
- JWTs are signed, not encrypted: Don't store secrets in them
- Two-token system: Access (short) + Refresh (long) for security + convenience
- SDK handles complexity: Automatic refresh, session management
- auth.uid() and auth.jwt(): Bridge between auth and RLS
- Storage matters: Choose based on security requirements
- Token theft is real: Use HTTPS, short expiry, secure storage
Module Summary
In this module, you've learned:
- The difference between authentication and authorization
- How GoTrue handles authentication in Supabase
- Various authentication methods and when to use them
- How JWT tokens work and carry identity
With authentication understood, you're ready to explore Row Level Security—the authorization layer that makes Supabase security so powerful.
JWTs are like badges that prove identity. The signature ensures they're genuine, but anyone who sees the badge can read what's on it. Treat them accordingly.

