Auto-Refresh CSRF Tokens with Axios Interceptors

This article explains how to implement automatic CSRF (Cross-Site Request Forgery) token refresh logic using Axios interceptors. You will learn how to intercept failed HTTP requests triggered by expired or missing CSRF tokens, fetch a new token from your backend, update authorization headers or cookies, and seamlessly replay the original request without disrupting the user experience.


The CSRF Refresh Workflow

When an application session remains idle, the CSRF token stored on the client can expire. Subsequent state-changing requests (POST, PUT, DELETE, PATCH) will fail, typically with an HTTP 403 Forbidden or 419 Authentication Timeout status code.

An Axios response interceptor resolves this issue by:

  1. Catching the CSRF expiration error.
  2. Requesting a new CSRF token from the server.
  3. Updating the default headers and the failed request's headers.
  4. Resending the original request.
  5. Queuing concurrent requests to avoid redundant token fetches.

Step-by-Step Implementation

1. Create an Axios Instance

Isolate your API logic within a dedicated Axios instance to prevent interceptor collisions with other HTTP calls.

import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true, // Necessary if CSRF tokens depend on cookies/sessions
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  },
});

export default apiClient;

2. Manage Concurrency and State

If multiple requests fail simultaneously due to an expired token, only one request should fetch a fresh token. Subsequent failed requests should wait in a queue until the new token is available.

let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
};

3. Implement the Response Interceptor

Attach an interceptor to apiClient.interceptors.response to capture expired token errors, fetch a replacement, and retry the request.

// Function to fetch a new token from the server
async function fetchCsrfToken() {
  // Using native axios to bypass instance interceptors and avoid loops
  const response = await axios.get('https://api.example.com/csrf-token', {
    withCredentials: true,
  });
  return response.data.csrfToken; // Adjust based on your API response structure
}

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Check for CSRF failure (adjust status code or error messages to match your backend)
    const isCsrfError =
      error.response &&
      (error.response.status === 419 ||
        (error.response.status === 403 &&
          error.response.data?.message?.includes('CSRF')));

    if (isCsrfError && !originalRequest._retry) {
      if (isRefreshing) {
        // If a refresh is already in progress, enqueue the request
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers['X-CSRF-TOKEN'] = token;
            return apiClient(originalRequest);
          })
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const newToken = await fetchCsrfToken();

        // Update default header for future requests
        apiClient.defaults.headers.common['X-CSRF-TOKEN'] = newToken;

        // Update the header for the retried request
        originalRequest.headers['X-CSRF-TOKEN'] = newToken;

        processQueue(null, newToken);
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Key Considerations