Authentication

HttpOnly Token Refresh: The Patterns That Actually Work

Tired of insecure localStorage JWTs? Learn the robust patterns for HttpOnly token refresh that actually work, keeping your app secure and your users logged in.

A

Alex Ivanov

Senior Software Engineer specializing in application security, authentication, and scalable web architectures.

7 min read21 views

Let's be honest: modern web authentication can feel like a minefield. You've probably heard the advice a hundred times: "Don't store JWTs in localStorage!" It's sound advice, of course. A single Cross-Site Scripting (XSS) vulnerability could give an attacker free rein to steal your user's token and impersonate them.

The solution, we're told, is to use HttpOnly cookies. By storing your sensitive refresh token in an HttpOnly cookie, you shield it from JavaScript, effectively neutralizing XSS-based token theft. But this security upgrade introduces a new puzzle: if your client-side code can't read the refresh token, how on earth do you use it to get a new access token?

That's the million-dollar question, and it's where many developers get stuck. The good news is there are robust, battle-tested patterns to solve this elegantly. Forget the complex workarounds and confusing tutorials. We're going to break down the patterns that actually work, giving you a secure and seamless authentication flow.

The Foundation: HttpOnly Cookies and In-Memory Tokens

First, let's establish our ground rules. A secure setup relies on two types of tokens:

  • Access Token: A short-lived JWT (e.g., 15 minutes) that's sent in the Authorization header of every API request. Because it's short-lived, the impact of a leak is minimized. This token is stored in memory on the client—think a simple variable in your application's state (e.g., in a React Context, Redux store, or Pinia store). It's never written to localStorage or sessionStorage.
  • Refresh Token: A long-lived token (e.g., 7 days) used only to get a new access token. This is the crown jewel you need to protect. It's stored in a secure, HttpOnly, SameSite=Strict cookie. The browser will automatically send it to your backend on specific requests, but your frontend JavaScript code can't touch it.

The initial login flow looks like this:

  1. The user submits their credentials to your /login endpoint.
  2. The server validates them and generates both an access token and a refresh token.
  3. The server sends the access token back in the JSON response body.
  4. The server sends the refresh token back in a Set-Cookie header.
// Server Response to POST /api/login

HTTP/1.1 200 OK
Set-Cookie: refreshToken=abc123xyz; HttpOnly; Path=/api/refresh_token; Secure; SameSite=Strict
Content-Type: application/json

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Your client-side app then stores the accessToken in memory and is ready to make authenticated requests. The refreshToken is now securely stored by the browser.

The Workhorse Pattern: Silent Refresh via API Interceptor

So, the access token expires in 15 minutes. What happens then? The user makes an API call, and it fails with a 401 Unauthorized error. Do you kick them back to the login page? Absolutely not. That's a terrible user experience.

Instead, we'll perform a "silent refresh" using an API client interceptor. Most modern HTTP clients, like Axios, have this powerful feature. An interceptor is just a piece of code that can hijack requests and responses before they are handled by your application code.

Advertisement

Here’s the flow:

  1. An API request is made with an expired access token.
  2. The server rejects it with a 401 Unauthorized status.
  3. Your API client's response interceptor catches this specific 401 error.
  4. The interceptor makes a request to a dedicated endpoint, like /api/refresh_token. Since this request is going to your backend, the browser automatically attaches the HttpOnly refresh token cookie. No JavaScript required!
  5. The backend validates the refresh token and issues a new access token in the response body.
  6. The interceptor receives the new access token, updates your in-memory store, and then automatically retries the original request that failed.

To the user, it's completely invisible. They might notice a tiny extra delay on one request every 15 minutes, but their session continues uninterrupted.

Here's a simplified example using Axios:

// api.js
import axios from 'axios';

const api = axios.create({ baseURL: '/api' });

// Store for the access token (in-memory)
let accessToken = null;

export const setToken = (token) => {
  accessToken = token;
};

// Request interceptor to add the token to headers
api.interceptors.request.use(config => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// Response interceptor for handling 401s
api.interceptors.response.use(
  (response) => response, // Simply return the response if it's successful
  async (error) => {
    const originalRequest = error.config;

    // If the error is 401 and we haven't already tried to refresh
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // Mark that we've tried to refresh

      try {
        // Call the refresh token endpoint (browser sends the HttpOnly cookie)
        const { data } = await axios.post('/api/refresh_token');
        
        // Update the in-memory token
        setToken(data.accessToken);

        // Update the header for the original request
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

        // Retry the original request
        return api(originalRequest);
      } catch (refreshError) {
        // If refresh fails, redirect to login
        console.error('Token refresh failed:', refreshError);
        // Here you would clear state and redirect to /login
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

export default api;

Tackling the "Dog Pile": Handling Concurrent Requests

The pattern above is great, but it has a flaw. What happens if you fire off three API requests at the same time when the access token has just expired? All three will fail with a 401. All three will trigger a call to /api/refresh_token. This is known as the "dog-pile effect." It's inefficient and can lead to race conditions where some requests get the new token while others don't.

We can solve this by adding a simple queuing mechanism to our interceptor.

The improved logic:

  1. The first request that fails with a 401 sets a flag, isRefreshing = true, and proceeds to call /api/refresh_token.
  2. Any subsequent requests that fail with a 401 while isRefreshing is true don't trigger their own refresh. Instead, they are paused and added to a queue.
  3. Once the first refresh call succeeds (or fails), isRefreshing is set back to false.
  4. If it succeeded, the new token is stored, and all the queued requests are retried with the fresh token.
  5. If it failed, all queued requests are rejected.

This ensures only one refresh request is ever active at a time. Here’s how you might modify the interceptor:

let isRefreshing = false;
let failedQueue = [];

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

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

    if (error.response.status === 401) {
      if (isRefreshing) {
        // If we are already refreshing, queue the request
        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 {
        const { data } = await axios.post('/api/refresh_token');
        setToken(data.accessToken);
        originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`;
        processQueue(null, data.accessToken);
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        // Redirect to login
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Important Considerations and Edge Cases

Getting the core logic right is 90% of the battle, but a robust system handles the edges gracefully.

What if the Refresh Token is Expired/Invalid?

Your /api/refresh_token endpoint must be robust. If it receives a cookie with an expired, invalid, or revoked refresh token, it should respond with a 401 Unauthorized or 403 Forbidden error. Your client-side interceptor must catch this failure from the refresh attempt, clear all authentication state (the in-memory access token), and force a redirect to the login page. The session is over.

Logging Out

A proper logout requires action on both the client and server. Your app should call a /api/logout endpoint. The server's only job is to clear the refresh token cookie. It can do this by sending back a Set-Cookie header with the same cookie name, an empty value, and an expiry date in the past. The client, upon receiving a successful response, then clears its in-memory access token and redirects to the login page.

CSRF Protection

A quick but critical note: if you are using cookies for authentication (which you are with this pattern!), you must protect against Cross-Site Request Forgery (CSRF) attacks. Because cookies are sent automatically by the browser, a malicious site could trick your user into making unintended requests to your backend. The standard solution is the Double Submit Cookie pattern, where the server provides a CSRF token that the client must include in a custom request header (e.g., X-CSRF-TOKEN). Your backend then verifies that the header matches the value in the CSRF cookie.

Conclusion: Secure and Seamless

Moving your JWT refresh token into an HttpOnly cookie is a significant security win. While it might seem complex at first, the API interceptor pattern provides an elegant and reusable solution that is completely transparent to the user.

By combining an in-memory access token with a cookie-based refresh token and a smart interceptor that handles silent refreshes and concurrent requests, you get the best of both worlds: robust protection against XSS and a smooth, uninterrupted user experience. This isn't just a theoretical setup; it's the pattern that powers countless secure, modern web applications. Now you can add it to your own toolkit.

Tags

You May Also Like