Careeroza
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.