# 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
1. [Understanding the Problem](#understanding-the-problem)
2. [How Supabase Tokens Work](#how-supabase-tokens-work)
3. [Why Users Get Logged Out](#why-users-get-logged-out)
4. [The Solution](#the-solution)
5. [Frontend Implementation](#frontend-implementation)
6. [Backend Behavior](#backend-behavior)
7. [Testing & Validation](#testing--validation)
8. [Troubleshooting](#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
1. User logs in → Receives access token (1 hour) + refresh token (30 days)
2. Access token expires after 1 hour
3. Frontend tries to refresh using refresh token
4. **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:
```json
{
"access_token": "eyJhbG...",
"refresh_token": "v1.MKh...",
"expires_in": 3600,
"token_type": "bearer",
"user": { ... }
}
```
Store in localStorage:
```javascript
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
1. **Proactive Refresh** - Don't wait for 401 errors
2. **Token Rotation Handling** - Always store new refresh token
3. **Cross-Tab Sync** - Coordinate token updates across tabs
4. **Graceful Degradation** - Clear messaging when re-login required
5. **Retry Logic** - Handle transient failures
---
## Frontend Implementation
### 1. Token Refresh Function
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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 ...;
}
```
---
## Backend Behavior
### Endpoint: POST /api/v1/auth/refresh-token
**Request:**
```json
{
"refresh_token": "v1.MKh..."
}
```
**Success Response (200):**
```json
{
"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: ` | 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:
1. Validates refresh token with Supabase
2. Returns **rotated tokens** (new refresh token on each call)
3. Verifies user still exists and is active
4. Provides clear, actionable error messages
5. 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:**
1. Login to application
2. Wait 55 minutes (or modify expiry for testing)
3. Make API request
4. Verify auto-refresh happens
5. Verify new tokens stored in localStorage
**Expected:** No logout, seamless refresh
### Test Case 2: Expired Refresh Token
**Steps:**
1. Login to application
2. Manually expire refresh token (modify timestamp or wait 30 days)
3. Try to make API request
4. Verify 401 error from backend
5. Verify user redirected to login with message
**Expected:** Graceful logout with "Session expired" message
### Test Case 3: Multi-Tab Sync
**Steps:**
1. Open app in Tab A and Tab B
2. Login in Tab A
3. Verify Tab B receives token update
4. Let token expire in Tab A
5. Verify Tab B also logs out
**Expected:** Both tabs stay synchronized
### Test Case 4: Network Failure During Refresh
**Steps:**
1. Login to application
2. Disable network
3. Wait for auto-refresh time
4. Verify retry logic activates
5. Re-enable network
6. Verify refresh succeeds
**Expected:** Retry successful, no logout
### Test Case 5: Race Condition (Multiple Refreshes)
**Steps:**
1. Login to application
2. Trigger multiple API calls simultaneously when token is about to expire
3. Verify only one refresh request sent
4. 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:**
1. **Check localStorage:**
```javascript
console.log('Access Token:', localStorage.getItem('access_token'));
console.log('Refresh Token:', localStorage.getItem('refresh_token'));
console.log('Expires At:', localStorage.getItem('expires_at'));
```
2. **Check if refresh is scheduled:**
```javascript
// Add to scheduleTokenRefresh function
console.log('Refresh scheduled for:', new Date(expiresAt).toLocaleString());
```
3. **Check backend logs:**
- Look for `Token refreshed successfully` (success)
- Look for `Token refresh error` (failure)
- Check error details for root cause
4. **Verify token rotation:**
- After refresh, refresh_token should be different
- If same token returned, rotation may be disabled
5. **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
```javascript
// BAD - Reactive
api.interceptors.response.use(null, async (error) => {
if (error.response.status === 401) {
await refreshTokens();
}
});
```
✅ **Fix:** Proactive refresh before expiry
```javascript
// GOOD - Proactive
scheduleTokenRefresh(expiresAt);
```
❌ **Mistake 2:** Not storing new refresh token
```javascript
// BAD - Old token reused
localStorage.setItem('access_token', data.access_token);
// Missing: localStorage.setItem('refresh_token', data.refresh_token);
```
✅ **Fix:** Always update both tokens
```javascript
// 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
```javascript
// BAD - Infinite loop
if (res.status === 401) {
await refreshTokens(); // Calls itself
}
```
✅ **Fix:** Logout on refresh failure
```javascript
// 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)
1. Add proactive token refresh scheduler
2. Update token after each refresh
3. Add 401 error interceptor
4. Test with short expiry times
### Phase 2: Enhanced UX
1. Add cross-tab synchronization
2. Show clear expiry messages
3. Add retry logic for network failures
4. Implement token refresh progress indicator
### Phase 3: Production Hardening
1. Switch to HttpOnly cookies (optional)
2. Add security monitoring/logging
3. Implement token refresh rate limiting
4. 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:
1. Enable debug logging in frontend
2. Check backend logs for refresh attempts
3. Verify Supabase project settings
4. Test with curl/Postman to isolate frontend vs. backend issues
5. 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