Back to Blog
Refresh Tokens: The Race Condition You Didn't Know You Had
8 min readJul 27, 20266 views

Refresh Tokens: The Race Condition You Didn't Know You Had

Implementing refresh token logic seems straightforward until your frontend makes multiple parallel API calls, all hitting an expired JWT at the exact same moment. Suddenly, your users are logged out, or worse, your server is hit with a thundering herd of refresh requests. There's a subtle but critic

BackendAPISoftware DesignDistributed SystemsSecurity
Share

by Sunil Band

The Silent Killer: When All Your API Calls Expire At Once

We've all been there: you've got a perfectly good JWT-based authentication system. Users log in, get an access token, and a refresh token. The access token is short-lived, the refresh token is long-lived. Your frontend dutifully attaches the access token to every request. When it expires, you catch the 401 Unauthorized, use the refresh token to get a new access/refresh pair, update your stored tokens, and retry the original failed request. It's standard practice, right?

But what happens when a user opens your app and it immediately fires off five or ten API calls simultaneously? And what if, at that exact moment, their access token just expired? Each of those requests will independently hit the server, get a 401, and each will trigger its own attempt to refresh the token. This isn't just inefficient; it's a race condition waiting to happen, potentially leading to unnecessary server load, compromised refresh token validity, and a broken user experience.

The Problem: A Thundering Herd of Refreshes

Imagine this sequence of events:

  1. User opens app. Frontend fires GET /data1, GET /data2, POST /update3.
  2. Access token has just expired. All three requests return 401 Unauthorized.
  3. Each request's error handler independently tries to refresh the token.
  4. refresh_data1 sends refresh token R1 to /refresh.
  5. refresh_data2 sends refresh token R1 to /refresh.
  6. refresh_data3 sends refresh token R1 to /refresh.

At this point, if your refresh token rotation strategy is robust (which it should be!), the first successful refresh request (refresh_data1) will invalidate R1 and return R2 (new access and refresh tokens). The subsequent refresh attempts (refresh_data2, refresh_data3) using the now-invalidated R1 will fail. This leads to a cascade of 401 errors, potentially logging the user out, even though a valid refresh did occur.

The Solution: A Single Source of Truth for Refreshing

The core idea is to ensure that only one refresh request is in flight at any given time. If other requests encounter an expired token while a refresh is pending, they should queue up and wait for the new tokens before retrying. If no refresh is in progress, they should initiate one.

I usually implement this using a simple queueing mechanism and a Promise to manage the in-flight refresh request. This ensures that all concurrent 401s funnel through a single point of token acquisition.

Let's look at a basic Axios interceptor setup, as that's a common way to handle this in React applications. I'm assuming you have localStorage or similar for token storage, but the principle applies regardless of storage mechanism.

typescript
import axios from 'axios';

// A global variable to track the in-flight refresh request
// Using a Promise here is key to queueing subsequent requests
let isRefreshing = false;
let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = [];

const processQueue = (error: Error | null, token: string | null = null) => {
  while (failedQueue.length) {
    const { resolve, reject } = failedQueue.shift()!;
    if (error) {
      reject(error);
    } else {
      resolve(token);
    }
  }
};

const apiClient = axios.create({
  baseURL: '/api',
  headers: {
    'Content-Type': 'application/json',
  },
});

apient.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem('accessToken');
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

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

    // If it's a 401 and we haven't tried to refresh yet (or it's not the refresh endpoint itself)
    if (error.response.status === 401 && !originalRequest._retry && originalRequest.url !== '/auth/refresh-token') {
      originalRequest._retry = true;

      if (!isRefreshing) {
        isRefreshing = true;
        console.log('Initiating token refresh...');
        try {
          const refreshToken = localStorage.getItem('refreshToken');
          if (!refreshToken) {
            throw new Error('No refresh token available. User must log in.');
          }
          // Make the actual refresh token request
          const response = await axios.post('/api/auth/refresh-token', { refreshToken });
          const { accessToken, refreshToken: newRefreshToken } = response.data;

          localStorage.setItem('accessToken', accessToken);
          localStorage.setItem('refreshToken', newRefreshToken);

          apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
          processQueue(null, accessToken); // Resolve all queued requests
          return apiClient(originalRequest); // Retry the original request with new token
        } catch (refreshError: any) {
          console.error('Token refresh failed:', refreshError);
          localStorage.removeItem('accessToken');
          localStorage.removeItem('refreshToken');
          processQueue(refreshError); // Reject all queued requests
          window.location.href = '/login'; // Redirect to login page
          return Promise.reject(refreshError); // Reject the original request
        } finally {
          isRefreshing = false;
        }
      } else {
        // A refresh is already in progress, queue the current request
        console.log('Refresh already in progress, queuing request...');
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
        .then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        })
        .catch((err) => {
          return Promise.reject(err);
        });
      }
    }
    return Promise.reject(error);
  }
);

export default apiClient;

Let's break down what's happening:

  • isRefreshing: This boolean flag prevents multiple refresh requests from being initiated. Only the first 401 that hits an expired token (and finds isRefreshing to be false) will kick off the refresh process.
  • failedQueue: This array stores the resolve and reject functions of all subsequent API calls that also received a 401 while a refresh was in progress. These requests are essentially paused.
  • processQueue: Once the refresh is complete (either successful or failed), this function iterates through failedQueue and resolves or rejects each waiting request. If successful, it provides the new access token.
  • originalRequest._retry: This flag prevents an infinite loop if the retried original request also fails for some reason other than token expiration, or if the refresh endpoint itself returns a 401.
  • originalRequest.url !== '/auth/refresh-token': Crucially, the refresh endpoint itself should not trigger this logic. If it fails with a 401, that's a genuine problem (e.g., refresh token invalid or expired) and the user should be logged out directly.

This setup ensures that:

  1. Only one network request to /auth/refresh-token happens when multiple 401s occur concurrently.
  2. All pending requests are held until new tokens are available.
  3. Upon successful refresh, all pending requests are retried with the new token.
  4. Upon failed refresh, all pending requests are rejected, and the user is typically logged out.

Server-Side Considerations: Refresh Token Rotation

While this client-side logic is crucial, it's equally important to have a robust server-side strategy for refresh tokens, specifically refresh token rotation with single-use tokens. When a client uses a refresh token to get a new access token, the server should:

  1. Invalidate the old refresh token. This is paramount. If the old token is compromised, it can only be used once.
  2. Issue a new refresh token (and access token).

If the server receives an already used refresh token, it should consider it suspicious. A good security measure here is to invalidate all refresh tokens for that user and force a re-login. This drastically limits the damage of a stolen refresh token, as it effectively becomes a one-time pad.

Without refresh token rotation, an attacker with a stolen refresh token could perpetually generate new access tokens. With rotation, if they use it, the legitimate user's subsequent attempt to refresh will fail, alerting them (or at least requiring a re-login) and invalidating the attacker's token.

Trade-offs and Gotchas

This pattern significantly improves the user experience and reduces server load. However, there are still things to consider:

  • Complexity: It adds a layer of complexity to your API client. Debugging token-related issues can become slightly harder as requests are now queued and retried.
  • Global State: The isRefreshing and failedQueue variables are essentially global state within the module. While necessary for this pattern, be mindful of how this interacts with server-side rendering environments if you're not careful (though typically this logic is client-side only).
  • User Experience on Failure: If the refresh token itself expires or is invalidated (e.g., revoked by an admin, or due to the rotation policy detecting suspicious activity), the user will be logged out. This is a security feature, not a bug, but it's important that your UI handles this gracefully, e.g., redirecting to a login page with a clear message.
  • Testing: Thoroughly test concurrent 401 scenarios. Use tools like Cypress or Playwright to simulate multiple parallel network requests and verify your interceptor behaves as expected.

I've seen many authentication implementations that miss this critical race condition. It's one of those subtle issues that doesn't show up in basic tests but rears its ugly head under real-world usage, especially in applications with rich UIs that make many API calls on load or during complex interactions.

Wrapping up

Don't let concurrent 401s lead to frustrated users or vulnerable refresh tokens. Implement a robust client-side refresh token queueing mechanism in your API client and pair it with strong server-side refresh token rotation. If you're using Axios, grab the interceptor code provided above and adapt it to your project. Run it, test it, and ensure your authentication layer is as solid as it can be. This isn't just about convenience; it's about security and system stability.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕