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:
- The page has been idle (no API calls to trigger refresh)
- The token expires between page load and user action
- 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
-
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
-
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 managementdist/js/chat/index.js- Chat interfacedist/js/public/portal-admin.js- Portal admindist/js/admin/users.js- User administrationdist/js/public/customer-dashboard.js- Customer dashboarddist/js/public/transactions.js- Transactionsdist/js/public/leaderboard.js- Leaderboard summarydist/js/public/leaderboard-contributors.js- Contributor leaderboarddist/js/public/leaderboard-content.js- Content leaderboarddist/js/public/balance-history.js- Balance historydist/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
-
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.
-
Solution: The
/refresh-jwtendpoint updates the stored JWT in the queue with the caller's current (fresh) JWT before processing.
Recommended Flow for Queue 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:
- Items immediately show "refreshing" status (optimistic UI update)
- API call is made to
/refresh-jwt - On success, page reloads to show "pending" status
- 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 |
Related Files
- supabase-auth.js - JWT utilities and session expired modal
- base_header.njk - JWT expiry display in user menu
- chat_header.njk - JWT expiry display in chat header
- document-management-queue.js - Queue management with "refreshing" state
- manageQueue/index.ts -
/refresh-jwtendpoint - supabase-store.ts - JWT debug logging
- process-queue-background.js - Netlify JWT handling