swiftops-backend / docs /api /auth /TOKEN_REFRESH_GUIDE.md
kamau1's picture
feat: app management
46739e8

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
  2. How Supabase Tokens Work
  3. Why Users Get Logged Out
  4. The Solution
  5. Frontend Implementation
  6. Backend Behavior
  7. Testing & Validation
  8. 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:

{
  "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

  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

// 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:

  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:

    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:

    // 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

// 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)

  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


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