Spaces:
Sleeping
Token Refresh & Session Management Guide
Overview
This guide explains why users get auto-logged out and how to implement robust token refresh logic in your frontend application.
TL;DR: Refresh tokens expire after 30 days. You need proactive refresh logic in your frontend to prevent auto-logout.
Table of Contents
- Understanding the Problem
- How Supabase Tokens Work
- Why Users Get Logged Out
- The Solution
- Frontend Implementation
- Backend Behavior
- Testing & Validation
- Troubleshooting
Understanding the Problem
The Error
ERROR: invalid JWT: unable to parse or verify signature, token has invalid claims: token is expired
This error appears when a refresh token has expired, not the access token.
What Happens
- User logs in β Receives access token (1 hour) + refresh token (30 days)
- Access token expires after 1 hour
- Frontend tries to refresh using refresh token
- If refresh token is also expired (30+ days old) β 401 Error β Auto-logout
How Supabase Tokens Work
Token Types
| Token Type | Lifespan | Purpose | Storage |
|---|---|---|---|
| Access Token | 1 hour | Authenticate API requests | Memory/LocalStorage |
| Refresh Token | 30 days | Get new access tokens | LocalStorage (HttpOnly cookies recommended) |
Token Rotation
Supabase uses rotating refresh tokens for security:
- Each time you refresh, you get a new refresh token
- The old refresh token becomes invalid
- This prevents replay attacks
- If rotation fails (network issues, race conditions), tokens can become invalid
Storage
After successful login, you receive:
{
"access_token": "eyJhbG...",
"refresh_token": "v1.MKh...",
"expires_in": 3600,
"token_type": "bearer",
"user": { ... }
}
Store in localStorage:
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('expires_at', new Date(Date.now() + data.expires_in * 1000).toISOString());
Why Users Get Logged Out
Scenario 1: Inactivity (Most Common)
- User doesn't use app for 30+ days
- Refresh token expires
- Next time they open app β Auto-logout
Solution: Display "Your session expired due to inactivity" message
Scenario 2: Token Rotation Failure
- Network interruption during refresh
- Race condition (multiple tabs refreshing simultaneously)
- Old refresh token stored, new one discarded
Solution: Implement retry logic and synchronize across tabs
Scenario 3: No Proactive Refresh
- Frontend waits for 401 error before refreshing
- User makes request with expired access token
- Backend returns 401
- Frontend tries to refresh, but refresh token also expired
Solution: Refresh tokens before they expire (5 minutes buffer)
Scenario 4: Multi-Tab/Device Issues
- User opens app in multiple tabs/devices
- Each tab/device tries to refresh independently
- Token rotation causes conflicts
- Tokens become invalid across sessions
Solution: Use BroadcastChannel API or shared worker for token sync
The Solution
Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FRONTEND β
β β
β 1. Login β Store tokens + Schedule refresh β
β 2. Auto-refresh 5 min before expiry β
β 3. Retry on 401 errors β
β 4. Sync tokens across tabs β
β 5. Graceful logout on refresh token expiry β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β BACKEND β
β β
β POST /api/v1/auth/refresh-token β
β β’ Validates refresh token with Supabase β
β β’ Returns new access + refresh tokens (rotated) β
β β’ Returns clear error if refresh token expired β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Principles
- Proactive Refresh - Don't wait for 401 errors
- Token Rotation Handling - Always store new refresh token
- Cross-Tab Sync - Coordinate token updates across tabs
- Graceful Degradation - Clear messaging when re-login required
- Retry Logic - Handle transient failures
Frontend Implementation
1. Token Refresh Function
// utils/auth.js
const API_BASE = process.env.REACT_APP_API_URL;
/**
* Refresh access token using refresh token
* Returns new tokens or null if refresh failed
*/
export const refreshTokens = async () => {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
console.warn('No refresh token found');
handleLogout();
return null;
}
try {
const res = await fetch(`${API_BASE}/auth/refresh-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (res.ok) {
const data = await res.json();
// Store new tokens (refresh token is rotated by Supabase)
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
localStorage.setItem('expires_at', expiresAt.toISOString());
console.log('β
Tokens refreshed successfully');
// Broadcast to other tabs
broadcastTokenUpdate(data);
// Schedule next refresh
scheduleTokenRefresh(expiresAt);
return data;
} else if (res.status === 401) {
// Refresh token expired
const error = await res.json();
console.error('Refresh token expired:', error.detail);
// Show user-friendly message
showToast('Your session has expired. Please log in again.', 'warning');
handleLogout();
return null;
} else {
throw new Error(`Refresh failed: ${res.status}`);
}
} catch (error) {
console.error('Token refresh error:', error);
handleLogout();
return null;
}
};
/**
* Logout user and clear all tokens
*/
const handleLogout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('expires_at');
// Redirect to login
window.location.href = '/login';
};
2. Proactive Token Refresh Scheduler
// utils/auth.js
let refreshTimeout = null;
/**
* Schedule automatic token refresh before expiration
* @param {Date|string} expiresAt - Token expiration timestamp
*/
export const scheduleTokenRefresh = (expiresAt) => {
// Clear any existing timeout
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
const now = Date.now();
const expiresAtMs = new Date(expiresAt).getTime();
const timeUntilExpiry = expiresAtMs - now;
// Refresh 5 minutes (300000ms) before expiration
const BUFFER_TIME = 5 * 60 * 1000;
const refreshTime = timeUntilExpiry - BUFFER_TIME;
if (refreshTime > 0) {
console.log(`π Token refresh scheduled in ${Math.round(refreshTime / 1000 / 60)} minutes`);
refreshTimeout = setTimeout(async () => {
console.log('β° Auto-refreshing token...');
await refreshTokens();
}, refreshTime);
} else {
// Token already expired or expires very soon - refresh immediately
console.log('β οΈ Token expired or expiring soon, refreshing now...');
refreshTokens();
}
};
/**
* Initialize token refresh on app startup
*/
export const initializeTokenRefresh = () => {
const expiresAt = localStorage.getItem('expires_at');
if (expiresAt) {
scheduleTokenRefresh(expiresAt);
} else {
console.warn('No token expiration found');
}
};
3. Cross-Tab Token Synchronization
// utils/auth.js
/**
* Broadcast token updates to other tabs/windows
*/
const broadcastTokenUpdate = (tokenData) => {
// Use BroadcastChannel API (modern browsers)
if ('BroadcastChannel' in window) {
const channel = new BroadcastChannel('auth_channel');
channel.postMessage({
type: 'TOKEN_UPDATED',
data: tokenData
});
}
// Fallback: localStorage events
localStorage.setItem('token_update_event', JSON.stringify({
timestamp: Date.now(),
data: tokenData
}));
};
/**
* Listen for token updates from other tabs
*/
export const setupTokenSyncListener = () => {
// BroadcastChannel listener
if ('BroadcastChannel' in window) {
const channel = new BroadcastChannel('auth_channel');
channel.onmessage = (event) => {
if (event.data.type === 'TOKEN_UPDATED') {
console.log('π‘ Received token update from another tab');
scheduleTokenRefresh(localStorage.getItem('expires_at'));
}
};
}
// localStorage listener (fallback)
window.addEventListener('storage', (event) => {
if (event.key === 'token_update_event' && event.newValue) {
console.log('π‘ Received token update via storage event');
scheduleTokenRefresh(localStorage.getItem('expires_at'));
}
});
};
4. API Interceptor for 401 Errors
// utils/api.js
import axios from 'axios';
import { refreshTokens } from './auth';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor - Add access token to all requests
api.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - Auto-retry on 401 with token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and not already retried
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
console.log('π 401 error detected, attempting token refresh...');
const newTokens = await refreshTokens();
if (newTokens) {
// Update authorization header with new token
originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`;
// Retry original request
return api(originalRequest);
} else {
// Refresh failed - user will be logged out by refreshTokens()
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);
export default api;
5. Login Flow Integration
// pages/Login.jsx
import { refreshTokens, scheduleTokenRefresh, setupTokenSyncListener } from '../utils/auth';
const handleLogin = async (email, password) => {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
const data = await res.json();
// Store tokens
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
localStorage.setItem('expires_at', expiresAt.toISOString());
// Schedule automatic refresh
scheduleTokenRefresh(expiresAt);
// Setup cross-tab sync
setupTokenSyncListener();
// Navigate to dashboard
navigate('/dashboard');
} else {
// Handle error
const error = await res.json();
showToast(error.detail, 'error');
}
} catch (error) {
console.error('Login error:', error);
showToast('Login failed. Please try again.', 'error');
}
};
6. App Initialization
// App.jsx or main.jsx
import { initializeTokenRefresh, setupTokenSyncListener } from './utils/auth';
function App() {
useEffect(() => {
// Check if user is logged in
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
// Initialize automatic token refresh
initializeTokenRefresh();
// Setup cross-tab synchronization
setupTokenSyncListener();
}
}, []);
return <Router>...</Router>;
}
Backend Behavior
Endpoint: POST /api/v1/auth/refresh-token
Request:
{
"refresh_token": "v1.MKh..."
}
Success Response (200):
{
"access_token": "eyJhbG...",
"refresh_token": "v1.XYz...",
"expires_in": 3600,
"token_type": "bearer",
"user": {
"id": "uuid",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"role": "platform_admin",
"is_active": true
}
}
Error Responses:
| Status Code | Error | Meaning | Frontend Action |
|---|---|---|---|
| 401 | Refresh token expired. Please log in again. |
Refresh token is expired (30+ days old) | Logout user, redirect to login with message |
| 401 | Token refresh failed: <error> |
Network or Supabase error | Retry once, then logout if still fails |
| 404 | User account no longer exists |
User deleted from database | Logout user, show account deletion message |
| 403 | Account is inactive. Contact support. |
User account deactivated | Logout user, show contact support message |
Backend Implementation Details
The backend:
- Validates refresh token with Supabase
- Returns rotated tokens (new refresh token on each call)
- Verifies user still exists and is active
- Provides clear, actionable error messages
- Logs all refresh attempts for security monitoring
Important: The backend does NOT store tokens in a database. Supabase manages all token storage and validation. The backend simply validates tokens and returns new ones.
Testing & Validation
Test Case 1: Normal Token Refresh
Steps:
- Login to application
- Wait 55 minutes (or modify expiry for testing)
- Make API request
- Verify auto-refresh happens
- Verify new tokens stored in localStorage
Expected: No logout, seamless refresh
Test Case 2: Expired Refresh Token
Steps:
- Login to application
- Manually expire refresh token (modify timestamp or wait 30 days)
- Try to make API request
- Verify 401 error from backend
- Verify user redirected to login with message
Expected: Graceful logout with "Session expired" message
Test Case 3: Multi-Tab Sync
Steps:
- Open app in Tab A and Tab B
- Login in Tab A
- Verify Tab B receives token update
- Let token expire in Tab A
- Verify Tab B also logs out
Expected: Both tabs stay synchronized
Test Case 4: Network Failure During Refresh
Steps:
- Login to application
- Disable network
- Wait for auto-refresh time
- Verify retry logic activates
- Re-enable network
- Verify refresh succeeds
Expected: Retry successful, no logout
Test Case 5: Race Condition (Multiple Refreshes)
Steps:
- Login to application
- Trigger multiple API calls simultaneously when token is about to expire
- Verify only one refresh request sent
- Verify all pending requests use new token
Expected: No duplicate refresh calls, all requests succeed
Troubleshooting
Issue: Users Still Getting Auto-Logged Out
Diagnosis Steps:
Check localStorage:
console.log('Access Token:', localStorage.getItem('access_token')); console.log('Refresh Token:', localStorage.getItem('refresh_token')); console.log('Expires At:', localStorage.getItem('expires_at'));Check if refresh is scheduled:
// Add to scheduleTokenRefresh function console.log('Refresh scheduled for:', new Date(expiresAt).toLocaleString());Check backend logs:
- Look for
Token refreshed successfully(success) - Look for
Token refresh error(failure) - Check error details for root cause
- Look for
Verify token rotation:
- After refresh, refresh_token should be different
- If same token returned, rotation may be disabled
Check Supabase settings:
- Verify JWT expiry settings in Supabase dashboard
- Confirm refresh token rotation is enabled
Common Mistakes
β Mistake 1: Waiting for 401 before refreshing
// BAD - Reactive
api.interceptors.response.use(null, async (error) => {
if (error.response.status === 401) {
await refreshTokens();
}
});
β Fix: Proactive refresh before expiry
// GOOD - Proactive
scheduleTokenRefresh(expiresAt);
β Mistake 2: Not storing new refresh token
// BAD - Old token reused
localStorage.setItem('access_token', data.access_token);
// Missing: localStorage.setItem('refresh_token', data.refresh_token);
β Fix: Always update both tokens
// GOOD - Both tokens updated
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
β Mistake 3: Not handling 401 on refresh endpoint
// BAD - Infinite loop
if (res.status === 401) {
await refreshTokens(); // Calls itself
}
β Fix: Logout on refresh failure
// GOOD - Break loop
if (res.status === 401) {
handleLogout();
}
Debug Checklist
- Refresh token is stored in localStorage
- scheduleTokenRefresh is called after login
- Timeout is set (check browser console)
- New tokens stored after refresh
- API interceptor configured correctly
- 401 errors trigger refresh attempt
- Failed refresh triggers logout
- Cross-tab sync working (optional but recommended)
- Error messages displayed to user
- Backend logs show refresh attempts
Best Practices Summary
DO β
- Refresh proactively 5 minutes before expiry
- Store new refresh token after each refresh
- Schedule next refresh after successful refresh
- Synchronize across tabs using BroadcastChannel
- Handle 401 gracefully with retry logic
- Show clear messages when re-login required
- Log all token operations for debugging
- Test token expiry scenarios thoroughly
DON'T β
- Don't wait for 401 to refresh tokens
- Don't ignore new refresh token from response
- Don't refresh on every 401 (check if not already refreshing)
- Don't store tokens in cookies unless using HttpOnly
- Don't create database table for tokens (Supabase handles this)
- Don't show technical errors to users
- Don't refresh more than once for same expiry
- Don't forget to clear tokens on logout
Security Considerations
Token Storage
LocalStorage (Current Implementation):
- β Simple to implement
- β Persists across page refreshes
- β Vulnerable to XSS attacks
- β Accessible to all scripts
HttpOnly Cookies (Recommended for Production):
- β Not accessible to JavaScript (XSS protection)
- β Automatically sent with requests
- β Can set secure and sameSite flags
- β Requires backend cookie handling
In-Memory Storage (Most Secure):
- β Not vulnerable to XSS
- β Cleared on page refresh
- β Poor UX (users logged out on refresh)
- β Doesn't work with multi-tab sync
Token Rotation
Supabase automatically rotates refresh tokens for security:
- Each refresh returns a new refresh token
- Old refresh token becomes invalid
- Prevents token replay attacks
- Reduces impact of stolen tokens
Monitoring
Log these events for security monitoring:
- Login attempts (success/failure)
- Token refresh attempts (success/failure)
- Logout events (user-initiated vs. auto-logout)
- Multiple failed refresh attempts (potential attack)
- Token expiry patterns (identify inactive users)
Migration Guide
If you're currently experiencing auto-logout issues:
Phase 1: Immediate Fix (Frontend)
- Add proactive token refresh scheduler
- Update token after each refresh
- Add 401 error interceptor
- Test with short expiry times
Phase 2: Enhanced UX
- Add cross-tab synchronization
- Show clear expiry messages
- Add retry logic for network failures
- Implement token refresh progress indicator
Phase 3: Production Hardening
- Switch to HttpOnly cookies (optional)
- Add security monitoring/logging
- Implement token refresh rate limiting
- Add user session management dashboard
Additional Resources
- Supabase Auth Docs: https://supabase.com/docs/guides/auth
- JWT Best Practices: https://tools.ietf.org/html/rfc8725
- Token Refresh Patterns: https://auth0.com/docs/secure/tokens/refresh-tokens
- BroadcastChannel API: https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
Support
If you continue experiencing issues:
- Enable debug logging in frontend
- Check backend logs for refresh attempts
- Verify Supabase project settings
- Test with curl/Postman to isolate frontend vs. backend issues
- Check for browser extensions blocking requests
Last Updated: November 18, 2025
Maintained By: SwiftOps Backend Team
Related Docs: AUTH_API_COMPLETE.md, GETTING_STARTED_AUTH.md