Web Development

Got a 401? How to Handle HttpOnly Token Refreshes

Struggling with 401 errors and HttpOnly cookies? Learn the seamless interceptor pattern to automatically refresh access tokens without compromising security.

A

Alex Ivanov

Full-stack developer with a passion for building secure, scalable, and user-friendly web applications.

7 min read17 views

Got a 401? How to Handle HttpOnly Token Refreshes

You’ve done it. You’ve built a sleek, modern web application with a secure authentication system. You’re using JSON Web Tokens (JWTs), with a short-lived access token and a long-lived refresh token stored safely in an HttpOnly cookie. You’ve patted yourself on the back, pushed to production, and all is well. Until the support tickets start rolling in: “The app keeps logging me out!”

You check the network tab and see it plain as day: a flurry of 401 Unauthorized responses. Your access token is expiring, as it should, but the user experience is falling apart. So, how do you handle this gracefully without compromising the security you worked so hard to implement? Welcome to the world of silent token refreshes.

The Token Dance: Access vs. Refresh

Before we dive into the solution, let’s quickly recap the roles of our two main characters:

  • Access Token: This is the key your app sends with every API request to prove it’s allowed to access a resource. For security, these are intentionally short-lived—think 15 minutes to an hour. If an access token is stolen, the attacker only has a small window to do damage.
  • Refresh Token: This is the long-term credential used for one purpose only: to get a new access token. It’s stored more securely and lives much longer, from days to months. When the access token expires, the app can use the refresh token to get a new one without forcing the user to log in again.

The server sends a 401 Unauthorized status when it receives a request with an expired or invalid access token. This is the signal that we need to perform the refresh dance.

The HttpOnly Dilemma: Secure but Silent

Storing tokens in JavaScript’s reach (like localStorage or a regular cookie) is a major security risk. It makes your application vulnerable to Cross-Site Scripting (XSS) attacks, where a malicious script could steal the token and impersonate your user.

This is why we use HttpOnly cookies for our precious refresh token. The HttpOnly flag tells the browser that this cookie should only be sent with HTTP requests to the server—it cannot be accessed by client-side JavaScript. At all.

This creates our central challenge: If our JavaScript can’t read the refresh token, how can it use it to fetch a new access token? The answer is beautifully simple: it doesn’t have to.

Advertisement

The Hero Arrives: The API Interceptor Pattern

The most elegant and standard way to handle this is by using an API client interceptor. Most modern HTTP clients, like Axios or even custom wrappers around the Fetch API, allow you to “intercept” requests and responses to add logic before they are sent or after they are received.

We'll focus on a response interceptor. It sits quietly, watching every response that comes back from the server. When it sees a 401, it springs into action.

Step-by-Step: The Silent Refresh Flow

  1. The Initial Request Fails: Your app makes a normal API call (e.g., to /api/user/profile) with an expired access token.
  2. The Interceptor Catches the 401: The server responds with 401 Unauthorized. Your response interceptor catches this error before it bubbles up to your application code.
  3. The Silent Refresh Request: The interceptor automatically makes a new request to your dedicated refresh endpoint (e.g., /api/auth/refresh-token). Since the refresh token is in an HttpOnly cookie, the browser automatically attaches it to this request. Your client-side code doesn't need to see or handle the token itself.
  4. The Server Issues a New Token: The backend receives the refresh request, validates the refresh token from the cookie, and if it's valid, generates a new access token. It sends this new access token back in the response body. Often, it will also issue a new refresh token (a practice called refresh token rotation) and set it as a new HttpOnly cookie.
  5. Retry the Original Request: The interceptor receives the new access token from the refresh endpoint's response. It then takes the original, failed request, updates its authorization header with the new token, and resends it.
  6. Success! This time, the request to /api/user/profile succeeds. The data is returned to your application, and the user never even knew a token expired. The experience is completely seamless.

Here’s what this looks like conceptually using Axios, a popular HTTP client:

// api.js - Your configured Axios instance
import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'https://api.yourapp.com',
  withCredentials: true // Important! This tells axios to send cookies
});

// Response interceptor for handling 401 errors
apiClient.interceptors.response.use(
  (response) => {
    // Any status code that lie within the range of 2xx cause this function to trigger
    return response;
  },
  async (error) => {
    const originalRequest = error.config;

    // Check if the error is a 401 and we haven't already retried
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // Mark this request as retried

      try {
        // Call the refresh token endpoint
        const { data } = await axios.post('https://api.yourapp.com/auth/refresh-token', {}, {
          withCredentials: true
        });

        // The server should return the new access token in the response
        // Note: We're not storing this in localStorage! 
        // It's held in memory just long enough to retry the request.
        const newAccessToken = data.accessToken; 

        // Update the original request's header with the new token
        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;

        // Retry the original request with the new token
        return apiClient(originalRequest);

      } catch (refreshError) {
        // If the refresh token fails, it's time to log out
        console.error('Unable to refresh token. Logging out.');
        // Here you would trigger a logout action (e.g., redirect to /login)
        return Promise.reject(refreshError);
      }
    }

    // For any other errors, just let them be thrown
    return Promise.reject(error);
  }
);

export default apiClient;

The Plot Twist: Handling Multiple Failed Requests

There's a common race condition to consider. What if your app fires off five API requests at the same time when the access token is expired? With the simple logic above, you’d make five separate calls to the refresh endpoint, which is inefficient and can cause its own problems.

Queuing Up for a New Token

A more robust solution involves creating a temporary “lock” and a queue. When the first 401 is detected, it initiates the refresh process. Any subsequent requests that also fail with a 401 are paused and added to a queue. Once the new token is successfully fetched, all the queued requests are released and retried with the fresh token.

This is often managed with a shared promise. The first failed request creates a promise for the token refresh, and subsequent requests just attach themselves to that same promise, ensuring the refresh logic only runs once.

When the Refresh Fails: The End of the Line

What if the call to /api/auth/refresh-token also fails? This usually happens if the refresh token itself has expired or has been invalidated (e.g., the user changed their password). In this case, the server will likely respond with a 401 or 403 Forbidden on the refresh endpoint.

This is your cue for a “hard logout.” Your interceptor should catch this failure, clear any remaining user state from your application, and redirect the user to the login page. There’s no recovering from this; the user’s session is truly over and they need to re-authenticate.

Putting It All Together: Seamless and Secure

Handling expired tokens can seem daunting, especially when you’re committed to the security of HttpOnly cookies. But by leveraging the interceptor pattern, you can create a solution that is both robust and completely invisible to the end-user.

It centralizes your authentication logic, keeps your component code clean, and maintains the critical boundary between your server-managed cookies and your client-side JavaScript. So next time you see a 401, don’t panic. See it as a signal to let your interceptor do its silent, heroic work.

Tags

You May Also Like