JWT Handling Best Practices for Frontend

Best practices for handling JWT expiration and refresh in frontend applications using Supabase. Modified: 2026-Jan-19 20:29:29 UTC

JWT Handling Best Practices for Frontend

Last Updated: 2026-01-12 Context: Fixes for "JWT expired 401 Unauthorized" errors in queue processing

Problem Statement

Supabase JWTs expire after 1 hour by default. If a frontend stores a JWT and uses it later without checking expiry, API calls will fail with 401 Unauthorized errors. This is especially problematic for:

  • Queue-based operations where items are submitted and processed later
  • Long-running pages where users may be idle
  • Background operations triggered after user has been on the page for a while

Root Cause

The Supabase JS client stores the session in localStorage:

{
  "access_token": "eyJ...",
  "expires_at": 1768279389,
  "refresh_token": "oy5tmejtk2u2",
  ...
}

While Supabase has auto-refresh capabilities, they may not trigger if:

  1. The page has been idle (no API calls to trigger refresh)
  2. The token expires between page load and user action
  3. The refresh token itself has expired (after 7 days of inactivity)

Current Implementation

The JWT handling utilities are implemented in supabase-auth.js and exposed globally:

Global Functions Available

// Get fresh token, refreshing if needed (5 min buffer by default)
const token = await window.ensureFreshToken(bufferSeconds);

// Check if session is valid without refreshing
const isValid = await window.isSessionValid();

// Get seconds until token expires (negative if expired)
const seconds = await window.getTokenTimeRemaining();

// Get current token without refresh check
const token = await window.getSupabaseToken();

UI Features

  1. JWT Expiry Display: Shows time remaining next to Logout button (e.g., "Logout 59m")

    • Gray: > 10 minutes remaining
    • Yellow: 5-10 minutes remaining
    • Red: < 5 minutes or expired
  2. Session Expired Modal: Automatically appears when token expires

    • Non-dismissable modal
    • Saves current page URL
    • Redirects to login, then back to the same page

Files Using ensureFreshToken()

All API-calling UI files have been updated to use ensureFreshToken():

  • dist/js/document-management-queue.js - Queue management
  • dist/js/chat/index.js - Chat interface
  • dist/js/public/portal-admin.js - Portal admin
  • dist/js/admin/users.js - User administration
  • dist/js/public/customer-dashboard.js - Customer dashboard
  • dist/js/public/transactions.js - Transactions
  • dist/js/public/leaderboard.js - Leaderboard summary
  • dist/js/public/leaderboard-contributors.js - Contributor leaderboard
  • dist/js/public/leaderboard-content.js - Content leaderboard
  • dist/js/public/balance-history.js - Balance history
  • dist/js/public/content-analytics.js - Content analytics

Solution: Always Ensure Fresh JWT Before API Calls

Pattern for API Helper Functions

Each UI file centralizes API calls through a helper function that uses ensureFreshToken():

async function callApi(endpoint, options = {}) {
  const { method = "GET", body = null } = options;

  // Use ensureFreshToken to get a valid, non-expired JWT
  let freshToken;
  try {
    if (typeof window.ensureFreshToken === "function") {
      freshToken = await window.ensureFreshToken();
    } else if (cachedToken) {
      // Fallback to cached token if ensureFreshToken not available
      freshToken = cachedToken;
    } else {
      throw new Error("Not authenticated");
    }
  } catch (authError) {
    console.error("Authentication error:", authError);
    // Redirect to login if session expired
    if (authError.message?.includes("log in")) {
      showToast?.("Session expired. Please log in again.", "warning");
      setTimeout(() => {
        window.redirectToLogin?.(window.location.pathname);
      }, 1500);
    }
    throw authError;
  }

  const response = await fetch(url, {
    method,
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${freshToken}`,
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  return response.json();
}

Session Expired Modal

When the JWT expires, a modal automatically appears to notify the user:

// This is handled automatically by supabase-auth.js
// The modal:
// - Appears when token expires (checked every 30 seconds, every 10 seconds when < 2 min remaining)
// - Is non-dismissable (no X button, can't click outside)
// - Saves current page URL for redirect after login
// - Shows "Log In" button that redirects to /login/

Queue-Specific Considerations

Why Queue Operations Are Especially Vulnerable

  1. Submission vs Processing Gap: A user submits a document with their current JWT. Hours or days later, they click "Process" - by then the stored JWT is long expired.

  2. Solution: The /refresh-jwt endpoint updates the stored JWT in the queue with the caller's current (fresh) JWT before processing.

1. User clicks "Process"
   ↓
2. Frontend calls ensureFreshToken()
   ↓
3. Frontend calls /refresh-jwt with fresh token (updates DB)
   ↓
4. Frontend calls /process-background with fresh token
   ↓
5. manageQueue passes fresh JWT to Netlify
   ↓
6. Netlify calls maintainSource with fresh JWT
   ↓
7. maintainSource uses fresh JWT for RLS-enforced operations

"Refreshing" State for Queue Items

When using the bulk "Refresh JWT" action in queue management:

  1. Items immediately show "refreshing" status (optimistic UI update)
  2. API call is made to /refresh-jwt
  3. On success, page reloads to show "pending" status
  4. On failure, items revert to original status

Debugging JWT Issues

Check JWT Expiry in Browser Console

// Use the global helper
const seconds = await window.getTokenTimeRemaining();
console.log(`Token expires in ${seconds} seconds`);

// Or decode manually
const {
  data: { session },
} = await supabase.auth.getSession();
if (session) {
  const payload = JSON.parse(atob(session.access_token.split(".")[1]));
  console.log("JWT expires at:", new Date(payload.exp * 1000));
  console.log("Current time:", new Date());
  console.log(
    "Seconds until expiry:",
    payload.exp - Math.floor(Date.now() / 1000)
  );
}

Server-Side JWT Debug Logs

The maintainSource function now logs JWT expiry info:

getClient: JWT Debug - exp=2026-01-13T03:43:09.000Z, now=2026-01-13T03:45:00.000Z, secondsUntilExp=3489

If secondsUntilExp is negative, the JWT was already expired when it reached the server.

Configuration

Supabase JWT Expiry (Default: 1 hour)

Can be configured in Supabase Dashboard → Authentication → Settings:

  • JWT expiry: Default 3600 seconds (1 hour)
  • Increase for fewer refresh issues, but less security
  • Decrease for better security, but more frequent refreshes

Refresh Token Expiry

  • Default: 7 days of inactivity
  • After this, user must log in again
  • Cannot be extended via token refresh

Frontend Configuration

In supabase-auth.js:

  • bufferSeconds = 300 - Refresh if token expires within 5 minutes (default)
  • JWT expiry display updates every 30 seconds (every 10 seconds when < 2 min remaining)
  • Session expired modal appears when token reaches 0

Summary

When Action
Before any API call ensureFreshToken() (handled by API helpers)
Before queue process ensureFreshToken() + call /refresh-jwt
Token expires Session expired modal appears automatically
User clicks Login Current page URL saved, redirect back after login
Auth error (401) Toast + redirect to login