How to Use Axios Interceptors for Authentication and Error Handling
Problem
When I build JavaScript applications with JWT authentication, I keep writing the same token-handling code in every API call:
// Fetch user dataconst getUser = async (userId) => { const token = localStorage.getItem('token'); const response = await fetch(`/api/users/${userId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { logout(); return; } return response.json();};
// Update userconst updateUser = async (userId, data) => { const token = localStorage.getItem('token'); const response = await fetch(`/api/users/${userId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (response.status === 401) { logout(); return; } return response.json();};
// Delete userconst deleteUser = async (userId) => { const token = localStorage.getItem('token'); // ... same pattern again};I’m repeating the same token attachment and error handling logic in every function. This violates DRY (Don’t Repeat Yourself) and makes my code hard to maintain.
Environment
- JavaScript ES6+
- Axios 1.x
- Node.js 18+ (or browser)
What happened?
I realized that when I need to change how tokens are stored or handled, I have to update every single API function. When I looked at Reddit discussions about axios vs fetch, I found this comment with 235 points:
“axios gets pulled in mostly for: interceptors (adding auth tokens globally, refresh logic, etc), better error handling out of the box”
This convinced me that interceptors are the right solution. But I wanted to understand exactly how they work and how to implement them properly.
The Solution: Axios Interceptors
Axios interceptors are middleware functions that let you transform requests before they’re sent and responses before they reach your code.
Here’s how they fit into the request flow:
┌─────────────┐ ┌──────────────┐ ┌────────┐ ┌───────────────┐ ┌────────────┐│ Your Code │ ──▶ │ Request │ ──▶ │ Server │ ──▶ │ Response │ ──▶ │ Your Code ││ │ │ Interceptor │ │ │ │ Interceptor │ │ │└─────────────┘ └──────────────┘ └────────┘ └───────────────┘ └────────────┘ │ │ ▼ ▼ Add auth token Handle 401 errors Add custom headers Transform response Log requests Refresh tokensStep 1: Basic Request Interceptor
I started with a simple request interceptor to attach tokens automatically:
import axios from 'axios';
const api = axios.create({ baseURL: 'https://api.example.com'});
// Request interceptor - attach token to every requestapi.interceptors.request.use( (config) => { const token = localStorage.getItem('authToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => { return Promise.reject(error); });
export default api;Now I can use this api instance everywhere without manually attaching tokens:
import api from './api';
// Token is automatically attached!const getUser = async (userId) => { const response = await api.get(`/users/${userId}`); return response.data;};Step 2: Response Interceptor for Error Handling
I added a response interceptor to handle authentication errors globally:
import axios from 'axios';
const api = axios.create({ baseURL: 'https://api.example.com'});
// Request interceptorapi.interceptors.request.use( (config) => { const token = localStorage.getItem('authToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error));
// Response interceptorapi.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // Token expired or invalid localStorage.removeItem('authToken'); window.location.href = '/login'; } return Promise.reject(error); });
export default api;This works, but I noticed a problem: what happens when multiple requests fail simultaneously due to token expiration?
Step 3: Token Refresh with Request Queue
JWT tokens expire. When they do, I need to refresh them without the user noticing. This is where it gets tricky:
- Multiple requests might fail at the same time
- I should only refresh the token once
- All failed requests should retry after the refresh
Here’s my solution:
import axios from 'axios';
const api = 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 = [];};
// Request interceptorapi.interceptors.request.use( (config) => { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error));
// Response interceptor with token refreshapi.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config;
// If 401 and haven't retried yet if (error.response?.status === 401 && !originalRequest._retry) { // If already refreshing, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; return api(originalRequest); }) .catch((err) => Promise.reject(err)); }
originalRequest._retry = true; isRefreshing = true;
try { // Refresh the token const refreshToken = localStorage.getItem('refreshToken'); const response = await axios.post('/auth/refresh', { refreshToken });
const { accessToken, refreshToken: newRefresh } = response.data;
// Store new tokens localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', newRefresh);
// Update default header api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
// Process queued requests processQueue(null, accessToken);
// Retry original request originalRequest.headers.Authorization = `Bearer ${accessToken}`; return api(originalRequest); } catch (refreshError) { // Refresh failed - logout processQueue(refreshError, null); localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); window.location.href = '/login'; return Promise.reject(refreshError); } finally { isRefreshing = false; } }
return Promise.reject(error); });
export default api;Let me explain the key parts:
isRefreshing flag: Prevents multiple refresh calls when several requests fail simultaneously.
failedQueue array: Stores pending requests while token refresh is in progress.
processQueue function: Resolves or rejects all queued requests after refresh completes.
Step 4: Centralized Error Handling
I also wanted user-friendly error messages instead of raw HTTP errors:
// Add this after the token refresh interceptorapi.interceptors.response.use( (response) => response, (error) => { const { response, request } = error;
// Network error (no response received) if (!response) { return Promise.reject(new Error('Network error. Check your connection.')); }
const { status, data } = response;
switch (status) { case 403: return Promise.reject(new Error('Access denied.')); case 404: return Promise.reject(new Error('Resource not found.')); case 422: return Promise.reject(new Error(data.message || 'Validation failed.')); case 500: return Promise.reject(new Error('Server error. Try again later.')); default: return Promise.reject(new Error(data.message || 'An error occurred.')); } });Now my API calls are clean and simple:
import api from './api';
const getUser = async (userId) => { try { const response = await api.get(`/users/${userId}`); return response.data; } catch (error) { // Error already transformed to user-friendly message console.error(error.message); throw error; }};Common Mistakes
When implementing interceptors, I made these mistakes:
1. Attaching interceptors multiple times
This happens when the module is imported multiple times. Each import adds another interceptor, causing duplicate requests. I fixed this by ensuring my api.js is a singleton.
2. Not handling refresh failures properly
Users got stuck in infinite redirect loops. I added the finally block to always reset isRefreshing.
3. Blocking all errors
I initially caught all errors in my response interceptor and returned generic messages. This hid useful debugging information. I now only handle specific status codes.
4. Storing tokens in localStorage
This is vulnerable to XSS attacks. For production apps, I use httpOnly cookies:
┌────────────────────────────────────────────────────────────────┐│ Token Storage Options │├─────────────────┬──────────────────────────────────────────────┤│ localStorage │ ❌ Vulnerable to XSS attacks ││ sessionStorage │ ❌ Still vulnerable to XSS ││ httpOnly cookie │ ✅ Not accessible via JavaScript ││ memory only │ ✅ Most secure, but lost on page refresh │└─────────────────┴──────────────────────────────────────────────┘Summary
In this post, I showed how to implement axios interceptors for authentication and error handling. The key takeaways:
- Request interceptors attach tokens automatically to every request
- Response interceptors handle 401 errors and refresh tokens seamlessly
- Request queuing prevents duplicate refresh calls during concurrent failures
- Centralized error handling provides consistent user-friendly messages
The beauty of interceptors is that I write the auth logic once, and it applies to every API call. No more repetitive token handling in individual functions.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Axios Documentation - Interceptors
- 👨💻 OWASP Token Storage Guidelines
- 👨💻 Reddit Discussion: Why developers choose axios over fetch
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments