Skip to main content
All posts

JWT Token Refresh Patterns in React 19: Avoiding the Silent Auth Death Spiral

22 May 2026

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:

  1. It’s built into React—no external dependency
  2. Token refresh is genuinely app-level state, not domain state
  3. 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.

What I

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch