Diagnosing Axios 401s: Token Attachment, Single-Flight Refresh, and Retry Loops
Repeated Axios 401s usually mean a token lifecycle bug, not an API bug. Diagnose header attachment, interceptor order, single-flight refresh, and retry loops in order.
17 Feb 2026, 17:15 UTC

The recognizable condition
Your app calls an API with Axios, the user is logged in, yet requests keep coming back with 401 Unauthorized. Sometimes it happens on every request after the token expires; sometimes it happens in bursts when several requests fire at once; sometimes the app ends up in a loop where the same request retries forever. All three symptoms usually trace back to how the bearer token is attached and refreshed, not to the API itself.
The useful takeaway: a 401 from your API is a signal about token lifecycle, and the fix is almost always in interceptor order, refresh de-duplication, or retry bookkeeping. This guide walks through the checks in the order that finds the cause fastest.
Cause and diagnostic table
| Symptom | Likely cause | Quick check |
|---|---|---|
| Every request 401s, even right after login | Authorization header never attached, or attached after the request was built | Inspect the request in browser dev tools: is the Authorization header present and well-formed? |
| Requests work, then all fail after some minutes | Access token expired, no refresh handling | Compare token lifetime with time of first failure |
| A burst of requests triggers multiple refresh calls | No single-flight guard on refresh | Count refresh requests in the network tab during a 401 burst |
| Retried request still 401s | Retry replays the stale config/header | Check whether the retried request carries the new token |
| Same request retries forever | No retry marker, so the retried request re-enters refresh | Look for repeated identical requests and refresh calls alternating |
Errors with no error.response treated as 401 | Network/CORS failure misclassified as token expiry | Log error.response; undefined means the request never got an HTTP response |
Check 1: Is the token actually on the wire?
Before touching refresh logic, confirm the boring case. Open the browser's network panel, find a failing request, and look at the request headers. You need Authorization: Bearer <token> on the original request. If it is missing, the request interceptor either is not registered, runs too late, or reads the token from a place that is empty at request time.
A minimal attachment interceptor looks like this and runs in your app's setup code, before any request is made:
api.interceptors.request.use((config) => {
const token = authStore.getAccessToken(); // sync read at request time
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});Two details matter here. First, read the token inside the interceptor, not once at module load — a token captured at startup goes stale. Second, mutate config.headers on the config Axios passes in, so the header is part of the request actually sent.
Check 2: Are you misclassifying non-401 errors?
Axios exposes the HTTP status at error.response.status. But network failures, timeouts, and CORS rejections produce an error with no response object at all. If your handler treats "any error" as "token expired," you will trigger refreshes on flaky Wi-Fi. Guard explicitly:
api.interceptors.response.use(
(res) => res,
async (error) => {
if (!error.response || error.response.status !== 401) {
return Promise.reject(error); // not a token problem
}
// ...refresh path
}
);Check 3: Does a burst of 401s start multiple refreshes?
If five requests are in flight when the token expires, all five can 401 at once. Without a guard, each one starts its own refresh call. Depending on the backend, concurrent refreshes can invalidate each other (rotating refresh tokens) or just waste calls. The standard fix is a shared in-flight promise: the first 401 starts the refresh, and every other 401 waits on the same promise.
let refreshPromise = null;
function refreshTokenOnce() {
if (!refreshPromise) {
refreshPromise = api.post('/auth/refresh').then((res) => {
authStore.setAccessToken(res.data.accessToken);
}).finally(() => {
refreshPromise = null; // allow the next expiry to refresh again
});
}
return refreshPromise;
}Verify this by forcing an expired token, firing several requests simultaneously, and confirming exactly one refresh request appears in the network trace.
Check 4: Does the retry use a fresh config and a loop guard?
After refresh resolves, replay the original request through the same Axios instance so the request interceptor re-runs and attaches the new token. Mark the config so a second 401 on the same request does not refresh again:
const original = error.config;
if (original._retried) {
return Promise.reject(error); // already tried once, give up
}
original._retried = true;
await refreshTokenOnce();
return api(original); // re-runs request interceptor, fresh tokenReplaying via api(original) preserves the method, body, params, and timeout from the original config. Two cautions: never mark or retry the refresh request itself (exclude the refresh URL explicitly), and cap retries at one per original request — a loop here looks like a hung app and hammers your auth server.
Check 5: What happens when refresh itself fails?
If the refresh endpoint returns 401 or 403, the session is not recoverable client-side. The correct response is to clear local auth state, reject all queued requests, and route to login — not to retry the original request. Also note the storage assumption behind all of this: keeping refresh tokens in localStorage exposes them to any XSS payload on your page. The more defensible pattern is a backend-issued HttpOnly; Secure; SameSite cookie for the refresh token, with only the short-lived access token held in memory. That is a backend decision as much as a client one, so flag it if your current design stores refresh tokens in web storage.
Escalation criteria
Stop debugging the client and escalate when any of these hold:
- The
Authorizationheader is present and fresh on the wire, but the API still returns 401 — that points at server-side token validation (clock skew, key rotation, audience mismatch), which you cannot fix in Axios. - Refresh succeeds but the new token is immediately rejected — likely a token issuance or propagation problem on the backend.
- 401s correlate with deploys or config changes rather than token lifetime — check environment variables and base URLs before touching interceptors.
Verifying the fix
Add temporary logging at interceptor entry, on each response status, at refresh start and completion, and on retry count. Then run three scenarios in a test environment: (1) shorten the access-token lifetime, confirm the first expired request triggers exactly one refresh and the original request succeeds once with the new token; (2) fire concurrent requests with an expired token and confirm one refresh call in the network trace; (3) make the refresh endpoint return 403 and confirm tokens are cleared, queued requests reject, and the user lands on the login screen. Interceptor and config details vary across Axios major versions, so confirm behavior against your installed version before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.