Axios Interceptors: Auth Headers and Centralized Error Handling Without the Pitfalls
Axios interceptors centralize auth headers and error handling — if you return the config, use a dedicated instance, and respect the reverse execution order. A worked setup plus the mistakes that bite.
16 Jul 2025, 01:22 UTC

The useful answer first
Axios interceptors let you run code on every request before it leaves and on every response (or error) before your calling code sees it. The two jobs they do best are injecting an Authorization header from one place and normalizing errors in one place, so individual API calls stay free of repeated token and try/catch boilerplate. The catch: you must return the config from a request interceptor, attach interceptors to a dedicated instance rather than the global axios object, and remember that request interceptors run in reverse registration order.
How the pipeline works
When you call client.get('/users'), axios builds a config object and pushes it through a chain: your request interceptors, then the actual HTTP dispatch, then your response interceptors, then your .then() or await. A request interceptor receives the config and must return it (or a promise resolving to it). A response interceptor receives the response on success, or an error on failure, via the two callbacks you pass to use(onFulfilled, onRejected).
One asymmetry trips people up: request interceptors execute in reverse registration order (the last one registered runs first), while response interceptors run in registration order. If you stack a logger and an auth interceptor and the log output looks backwards, this is why. You can verify it yourself by registering two request interceptors that log their names and watching the console.
A worked configuration: instance, auth header, error handling
The recommended pattern is a dedicated instance via axios.create(). Interceptors on the global axios default instance affect every request in the process — including any third-party library that happens to use axios internally.
// api.js — runs in the browser or Node; no special permissions needed\nimport axios from 'axios';\n\nexport const api = axios.create({\n baseURL: 'https://api.example.com',\n timeout: 10000,\n});\n\n// 1. Attach the token to every outgoing request\napi.interceptors.request.use((config) => {\n const token = localStorage.getItem('access_token');\n if (token) {\n config.headers.Authorization = `Bearer ${token}`;\n }\n return config; // forgetting this line silently breaks the request\n});\n\n// 2. Centralize error handling\napi.interceptors.response.use(\n (response) => response, // pass successes through untouched\n (error) => {\n if (error.response) {\n // Server answered with a non-2xx status\n if (error.response.status === 401) {\n window.location.assign('/login');\n }\n } else if (error.request) {\n // Request was sent but no response arrived (timeout, network down)\n console.error('No response from server', error.message);\n }\n return Promise.reject(error); // let callers still handle specifics\n }\n);Call sites then stay clean: const { data } = await api.get('/users') carries the token automatically, and a 401 redirects without each caller checking for it.
Verifying it actually works
Don't trust the code by reading it. Three quick checks:
- Make any request and open the browser's Network tab (or point
baseURLat a request-echo service) to confirm theAuthorizationheader is present on the outgoing request. - Hit an endpoint that returns 401 and confirm the redirect fires and that
error.response.statusis readable in the handler. - Disable the network and repeat: confirm
error.responseisundefinedand yourerror.requestbranch runs instead. Handlers that assumeerror.responsealways exists crash on timeouts.
Limits and the mistakes that bite in production
Removing interceptors
Each use() call returns an ID. In React/Vue components or test suites that re-run setup, interceptors accumulate unless you eject them:
const id = api.interceptors.request.use(myFn);\n// later, e.g. in a cleanup function or afterEach:\napi.interceptors.request.eject(id);Duplicate interceptors mean duplicate headers and doubled log lines — and in a token-refresh setup, multiple simultaneous refresh calls.
Token refresh needs a retry guard
A common extension is: on 401, call the refresh endpoint, then retry the original request with api(originalConfig). Without a flag on the config, a persistently failing token loops forever:
if (error.response?.status === 401 && !error.config._retried) {\n error.config._retried = true;\n await refreshToken();\n return api(error.config);\n}Any retry logic should also include a counter or backoff; blind retries amplify an outage by hammering an already-failing server.
Async interceptors and shared state
An async request interceptor that throws, or that forgets to return the config, leaves the request hanging or failing with a confusing error. Always return the config or a rejected promise. Also avoid storing per-user data (like a token) on shared defaults such as api.defaults.headers in server-side code — concurrent requests from different users can leak headers into each other. Set headers per-request inside the interceptor, reading from request-scoped storage.
When interceptors are the wrong tool
Interceptors are global to their instance, so they're a poor fit for one-off behavior — a single request that needs a different timeout or header should just pass that in its own config. And because they run on every call, keep them fast and side-effect-light; heavy synchronous work in a request interceptor delays every request the instance makes. If you find yourself registering many interceptors with ordering dependencies, that's a sign the logic belongs in an explicit wrapper function instead, where execution order is visible in the code rather than implied by registration order.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.