Fix Axios Token Refresh Race Conditions
When multiple asynchronous HTTP requests expire simultaneously, applications often fire multiple refresh token requests at once, leading to race conditions, token rotation failures, and unexpected user logouts. The industry-standard solution to this problem is implementing an Axios response interceptor that uses a request queue alongside a single mutex-like execution flag. This ensures that only the first expired request initiates the token refresh, while all subsequent concurrent requests are paused and queued until the new access token is acquired and ready for replay.
The Race Condition Problem
In modern web applications using access and refresh tokens (often
with refresh token rotation), a client might fire three or four API
requests concurrently on a dashboard view. If the access token has
expired, every single request returns a 401 Unauthorized
status at roughly the same millisecond.
Without proper synchronization, the client application will attempt to call the refresh endpoint once for each failed request. If the authentication backend enforces single-use refresh token rotation, the first refresh call succeeds and invalidates the old refresh token. The subsequent refresh calls will then fail, causing the backend to flag a potential token reuse attack and immediately revoke the user's session entirely.
The Solution: Interceptors and a Subscriber Queue
To handle concurrent 401 errors safely, the Axios instance must coordinate retries using three core elements:
- A State Flag (
isRefreshing): Tracks whether a token refresh is currently in progress. - A Failed Request Queue: An array storing the promise resolution callbacks of all requests that failed with a 401 while the refresh operation was running.
- An Axios Response Interceptor: Intercepts
401errors, delegates the refresh call, pauses secondary requests, and retries all queued requests once the new token is available.
Implementation
Below is a complete, production-ready implementation of a synchronized token refresh mechanism using Axios:
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'https://api.example.com',
});
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Reject immediately if error is not 401 or request was already retried
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
// Handle token refresh endpoint failure specifically to avoid infinite loops
if (originalRequest.url === '/auth/refresh') {
return Promise.reject(error);
}
if (isRefreshing) {
// If a refresh is already in progress, enqueue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Call the refresh endpoint
const response = await axios.post('https://api.example.com/auth/refresh', {}, {
withCredentials: true // If using HTTP-only cookies
});
const newToken = response.data.accessToken;
// Update default headers for future requests
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
// Resolve all pending requests in the queue
processQueue(null, newToken);
// Retry the original request that triggered the refresh
return apiClient(originalRequest);
} catch (refreshError) {
// If refresh fails, reject all queued requests and trigger logout
processQueue(refreshError, null);
// Optional: Dispatch a global logout event or clear storage
// window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export default apiClient;Key Execution Steps
- Detection: When a request receives a
401 Unauthorizedstatus, the response interceptor catches it. The_retryflag ensures that a request is never retried more than once, preventing infinite retry loops. - Queuing: If
isRefreshingistrue, a newPromiseis returned. Itsresolveandrejectfunctions are pushed intofailedQueue. Execution halts here for this specific request. - Execution: If
isRefreshingisfalse, the flag is immediately flipped totrue, and the refresh API call is executed using an independent Axios call (avoiding the interceptor). - Resolution: Upon a successful refresh,
processQueueiterates over all queued callbacks, resolving them with the new token. Each queued request then updates itsAuthorizationheader and replays viaapiClient(originalRequest). - Cleanup: In the
finallyblock,isRefreshingis reset tofalse. If the refresh fails, all queued promises are rejected, ensuring that pending operations terminate cleanly and the application can safely redirect to the login page.