Skip to content

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:

api-calls.js
// Fetch user data
const 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 user
const 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 user
const 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:

request-flow.txt
┌─────────────┐ ┌──────────────┐ ┌────────┐ ┌───────────────┐ ┌────────────┐
│ Your Code │ ──▶ │ Request │ ──▶ │ Server │ ──▶ │ Response │ ──▶ │ Your Code │
│ │ │ Interceptor │ │ │ │ Interceptor │ │ │
└─────────────┘ └──────────────┘ └────────┘ └───────────────┘ └────────────┘
│ │
▼ ▼
Add auth token Handle 401 errors
Add custom headers Transform response
Log requests Refresh tokens

Step 1: Basic Request Interceptor

I started with a simple request interceptor to attach tokens automatically:

api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com'
});
// Request interceptor - attach token to every request
api.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:

userService.js
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:

api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com'
});
// Request interceptor
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
api.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:

  1. Multiple requests might fail at the same time
  2. I should only refresh the token once
  3. All failed requests should retry after the refresh

Here’s my solution:

api.js
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 interceptor
api.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 refresh
api.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:

api.js
// Add this after the token refresh interceptor
api.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:

userService.js
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.txt
┌────────────────────────────────────────────────────────────────┐
│ 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:

  1. Request interceptors attach tokens automatically to every request
  2. Response interceptors handle 401 errors and refresh tokens seamlessly
  3. Request queuing prevents duplicate refresh calls during concurrent failures
  4. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments