JWT Token Refresh Patterns in React 19: Avoiding the Silent Auth Death Spiral
I’ve watched authentication break in production more times than I want to admit. Usually it’s silent—users get logged out mid-action, requests fail with 401s, and nobody notices until support tickets pile up. The culprit? Naive token refresh logic that doesn’t handle concurrent requests.
Most solutions I see are either bloated (Redux middleware with retry queues) or fragile (localStorage checks that fail when two requests hit simultaneously). I’m going to show you the approach I use in CitizenApp: minimal, correct, and battle-tested with concurrent traffic.
The Problem: Why Simple Token Refresh Fails
Let me paint a scenario. User opens your app. Token expires. They click a button that triggers three API calls simultaneously. Here’s what happens with naive refresh:
// ❌ Bad: Each request independently tries to refresh
async function apiCall(endpoint: string) {
let token = localStorage.getItem('token');
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) {
// All three requests do this at the same time
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') })
});
// ...
}
}
You just sent three simultaneous refresh requests. Your backend probably revoked all but one token for security reasons. Congrats, now all three requests fail with 401 again, and you’re in a retry loop. This is the “silent auth death spiral.”
The Solution: React Context + AbortController
I prefer React Context for this over Redux because:
- It’s built into React—no external dependency
- Token refresh is genuinely app-level state, not domain state
- It’s easier to reason about with AbortControllers for request queuing
Here’s the implementation I use:
// auth.context.tsx
import React, { createContext, useCallback, useRef, useEffect } from 'react';
interface AuthContextType {
token: string | null;
refreshToken: string | null;
setTokens: (token: string, refreshToken: string) => void;
clearTokens: () => void;
getValidToken: () => Promise<string>;
}
export const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = React.useState<string | null>(null);
const [refreshToken, setRefreshToken] = React.useState<string | null>(null);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Load tokens from secure storage on mount
useEffect(() => {
const token = sessionStorage.getItem('token');
const refreshToken = sessionStorage.getItem('refreshToken');
if (token && refreshToken) {
setToken(token);
setRefreshToken(refreshToken);
}
}, []);
const performRefresh = useCallback(
async (refreshTokenValue: string): Promise<string> => {
// Cancel previous refresh attempt if still in flight
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: refreshTokenValue }),
signal: abortControllerRef.current.signal
});
if (res.status === 401 || res.status === 403) {
// Refresh token expired—user must re-login
clearTokens();
throw new Error('Refresh token expired');
}
if (!res.ok) {
throw new Error(`Refresh failed: ${res.statusText}`);
}
const { token: newToken, refreshToken: newRefreshToken } = await res.json();
setToken(newToken);
setRefreshToken(newRefreshToken);
sessionStorage.setItem('token', newToken);
sessionStorage.setItem('refreshToken', newRefreshToken);
return newToken;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Refresh cancelled');
}
throw error;
}
},
[]
);
const getValidToken = useCallback(async (): Promise<string> => {
// If we already have a refresh in flight, wait for it
if (refreshPromiseRef.current) {
return refreshPromiseRef.current;
}
// If token exists and isn't expired, return it
if (token && !isTokenExpired(token)) {
return token;
}
// Otherwise, start a refresh
if (!refreshToken) {
throw new Error('No refresh token available');
}
refreshPromiseRef.current = performRefresh(refreshToken)
.finally(() => {
refreshPromiseRef.current = null;
});
return refreshPromiseRef.current;
}, [token, refreshToken, performRefresh]);
return (
<AuthContext.Provider
value={{
token,
refreshToken,
setTokens: (t, rt) => {
setToken(t);
setRefreshToken(rt);
sessionStorage.setItem('token', t);
sessionStorage.setItem('refreshToken', rt);
},
clearTokens: () => {
setToken(null);
setRefreshToken(null);
sessionStorage.clear();
},
getValidToken
}}
>
{children}
</AuthContext.Provider>
);
}
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
Now your fetch interceptor:
// api.ts
import { AuthContext } from './auth.context';
export function useApi() {
const authContext = React.useContext(AuthContext);
return useCallback(
async (endpoint: string, options: RequestInit = {}) => {
if (!authContext) throw new Error('AuthProvider not found');
try {
const token = await authContext.getValidToken();
const headers = new Headers(options.headers || {});
headers.set('Authorization', `Bearer ${token}`);
let res = await fetch(endpoint, { ...options, headers });
// If still 401 after refresh attempt, we're truly unauthorized
if (res.status === 401) {
authContext.clearTokens();
window.location.href = '/login';
throw new Error('Session expired');
}
return res;
} catch (error) {
if (error instanceof Error && error.message === 'No refresh token available') {
window.location.href = '/login';
}
throw error;
}
},
[authContext]
);
}
Why This Works
Concurrency handling: The refreshPromiseRef ensures only one refresh happens at a time. Multiple concurrent requests wait for the same promise.
// Three requests hit simultaneously
await Promise.all([
useApi()('/api/users'),
useApi()('/api/posts'),
useApi()('/api/notifications')
]);
// All three call getValidToken() → same refresh promise → single refresh request
AbortController prevents stale refreshes: If a user navigates away or a component unmounts mid-refresh, we abort the request instead of updating state on an unmounted component.
Session storage, not localStorage: Tokens should die with the browser tab. I never use localStorage for auth—it survives XSS attacks longer than it should.
The FastAPI Backend
Keep it simple:
# auth.py
from fastapi import HTTPException, status
from jose import JWTError, jwt
from datetime import datetime, timedelta
SECRET = "your-secret"
def create_tokens(user_id: str):
access_payload = {
'sub': user_id,
'exp': datetime.utcnow() + timedelta(minutes=15),
'type': 'access'
}
refresh_payload = {
'sub': user_id,
'exp': datetime.utcnow() + timedelta(days=7),
'type': 'refresh'
}
return {
'token': jwt.encode(access_payload, SECRET),
'refreshToken': jwt.encode(refresh_payload, SECRET)
}
@app.post('/api/auth/refresh')
async def refresh(data: dict):
try:
payload = jwt.decode(data['refreshToken'], SECRET, algorithms=['HS256'])
if payload.get('type') != 'refresh':
raise HTTPException(status_code=401)
return create_tokens(payload['sub'])
except JWTError:
raise HTTPException(status_code=403)
Gotcha: Token Expiration Skew
This burned me: I assumed isTokenExpired() would perfectly predict when a token fails. It doesn’t. Clock skew between client and server, plus network latency, means a token can appear valid client-side but fail server-side by milliseconds.
Fix: Add a 30-second buffer:
function isTokenExpired(token: string, bufferSeconds = 30): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now() + bufferSeconds * 1000;
} catch {
return true;
}
}
This makes the client refresh slightly early, avoiding the 401 entirely on most requests.