Handling Expired Tokens in Axios: Refresh-and-Retry with Response Interceptors
A practical axios v1.x pattern: use a response interceptor to catch 401s, refresh the auth token once, and retry the original request — with the loop guard and shared-promise tricks that keep it safe under concurrency.
02 Apr 2026, 01:02 UTC

Your single-page app fires off an API call, the access token expired two minutes ago, and the server answers 401. The naive fix — redirect to login — throws away the user's state for what is often a recoverable situation. The better fix is a response interceptor that quietly refreshes the token and retries the original request, so the caller never knows anything happened.
This post walks through that pattern using axios v1.x: a dedicated instance, a request interceptor that attaches the token, and a response interceptor that handles the 401, refreshes once, and retries — with the two guards you need to avoid infinite loops and duplicate refresh calls.
Why an interceptor instead of wrapping every call
Axios interceptors are functions registered on an instance via axios.interceptors.request.use() and axios.interceptors.response.use(). Each takes an onFulfilled and an onRejected handler, and they run for every request or response passing through that instance. That "every request" property is exactly what you want for auth: instead of remembering to attach a token or handle 401s in dozens of call sites, you write it once.
The first engineering decision: create a dedicated instance with axios.create() rather than registering interceptors on the global axios object. Interceptors on the default instance affect every request in your app — including calls to third-party APIs that should never see your auth header. Scoping to an instance keeps side effects contained.
The worked example
Run this in your application's API layer (browser or Node — axios works in both). It assumes axios v1.x and a backend with a POST /auth/refresh endpoint that accepts a refresh token and returns a new access token. Token storage is deliberately abstracted behind getAccessToken()/setTokens() — whether you use memory, cookies, or web storage is a security decision that depends on your threat model, and this pattern works the same either way.
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
});
// Attach the current access token to every outgoing request.
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let refreshPromise = null; // shared in-flight refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
// Only handle HTTP 401s, and only once per request.
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
original._retry = true;
try {
// Share one refresh across concurrent 401s.
refreshPromise = refreshPromise ?? refreshTokens();
await refreshPromise;
refreshPromise = null;
return api(original); // retry with the new token attached
} catch (refreshError) {
refreshPromise = null;
// Refresh failed — session is really over.
forceLogout();
return Promise.reject(refreshError);
}
}
);Three details in this code do the heavy lifting:
- The
_retryflag. Axios retries the original request through the same instance, which means the interceptor fires again for the retried response. Without a marker onerror.config, a persistent auth failure loops forever. The flag is just a custom property on the config object — axios passes config through untouched. - The shared
refreshPromise. If three requests fail with 401 at the same time, you don't want three refresh calls — many backends rotate refresh tokens, so a second refresh with the old token can invalidate the session. Subsequent 401s await the in-flight promise instead of starting their own. - The
error.responsecheck. Axios distinguishes failure types by which fields are populated:error.responseexists when the server answered with an error status,error.requestexists (withoutresponse) when no answer arrived, and neither exists for setup errors. Checkingerror.response?.status === 401ensures you only trigger refresh logic for an actual HTTP 401, not a network outage.
Trade-offs and limitations
The retry-once pattern is simple, but it has sharp edges worth knowing:
- It can mask backend bugs. If the server is wrongly returning 401 for valid tokens, the interceptor hides it behind a refresh-and-retry that eventually "works" or fails confusingly. Log refresh attempts somewhere observable.
- It assumes refresh is cheap and idempotent. If your refresh endpoint is slow, every expired-token burst stalls on the shared promise. That's usually correct behavior, but set a timeout on the refresh call so a hung refresh doesn't freeze the whole app.
- Retried requests must be safe to repeat. Retrying a GET is harmless; blindly retrying a non-idempotent POST after a 401 needs care, since you can't always know whether the server processed it before rejecting. In practice most servers reject before processing, but confirm for your API.
- Version sensitivity. This example targets axios v1.x. The interceptor API is stable across v0.x and v1.x, but TypeScript types and some error-shape details differ; check the README for your installed version.
Verify it actually works
Don't ship this on faith. Point the instance at a mock server (or axios's mock adapter in tests) that returns 401 once, then 200. Confirm: the response interceptor's onRejected branch receives error.response.status and error.config; the original request completes successfully after refresh; and two concurrent requests with an expired token produce exactly one refresh call. If you see two, your shared-promise guard has a gap. Finally, test the failure path: when refresh itself returns 401, the interceptor should reject cleanly rather than loop — that's how you know the _retry flag is doing its job.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.