Silent Refresh in React

Access Token in Memory

Now the access token lives in a normal JavaScript variable: an XSS script on another page cannot find it in localStorage, and it disappears when the tab closes. On page load, the app calls /auth/refresh; the browser sends the HttpOnly cookie, and we get a new access token without asking for the password.

// src/api.js
import axios from 'axios';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
let accessToken = null;
let refreshPromise = null;

export const setAccessToken = (token) => { accessToken = token; };

export const api = axios.create({ baseURL: API_URL, withCredentials: true });

api.interceptors.request.use((config) => {
  if (accessToken) config.headers.Authorization = 'Bearer ' + accessToken;
  return config;
});

// One refresh at a time, even if 5 requests fail with 401 together
export function refreshAccessToken() {
  if (!refreshPromise) {
    refreshPromise = axios
      .post(API_URL + '/auth/refresh', null, { withCredentials: true })
      .then((res) => {
        accessToken = res.data.accessToken;
        return res.data;
      })
      .finally(() => { refreshPromise = null; });
  }
  return refreshPromise;
}

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;
    const isAuthCall = original?.url?.startsWith('/auth/');
    if (error.response?.status === 401 && original && !original._retry && !isAuthCall) {
      original._retry = true;
      try {
        await refreshAccessToken();
        return api(original);              // retry with the new token
      } catch {
        accessToken = null;
        window.location.assign('/login?expired=1');
      }
    }
    return Promise.reject(error);
  },
);

Restoring the Session on Page Load

// src/auth/AuthContext.jsx
import { createContext, useContext, useEffect, useState } from 'react';
import { api, refreshAccessToken, setAccessToken } from '../api';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    refreshAccessToken()
      .then((data) => setUser(data.user))
      .catch(() => setUser(null))          // no valid cookie: not logged in
      .finally(() => setReady(true));
  }, []);

  async function login(email, password) {
    const { data } = await api.post('/auth/login', { email, password });
    setAccessToken(data.accessToken);
    setUser(data.user);
    return data.user;
  }

  async function logout() {
    try { await api.post('/auth/logout'); } finally {
      setAccessToken(null);
      setUser(null);
    }
  }

  const hasRole = (role) => !!user && user.roles.includes(role);

  if (!ready) return <p>Loading...</p>;
  return (
    <AuthContext.Provider value={{ user, login, logout, hasRole }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

Tip

Tip: The single shared refreshPromise matters. Without it, a page that loads orders, cart and profile at once would send three refresh calls with the same cookie; with rotation, the second and third would look like token reuse and log the user out. React 18 Strict Mode runs effects twice in development, which shows this bug quickly.