Back to blog

Implementing Refresh Tokens Securely: A Guide to Silent Authentication

Single-Page Applications (SPAs) require users to remain logged in securely without constantly prompting them for credentials. However, if your authentication system relies on a single JSON Web Token (JWT) with a long expiration time (e.g., 30 days), a stolen token grants attackers unrestricted access.

Conversely, setting a short expiration time (e.g., 15 minutes) protects against theft but ruins user experience by forcing users to log in every 15 minutes.

The industry-standard solution is implementing a Dual Token Strategy utilizing short-lived Access Tokens and long-lived Refresh Tokens. In this guide, we will explore this strategy, examine security requirements, and implement silent refresh handling using Axios interceptors.

The Dual Token Architecture

Instead of issuing a single token, your authentication backend returns two distinct tokens upon login:

  1. Access Token (Short-Lived):
    • Lifetime: 10 to 15 minutes.
    • Scope: Used to authenticate client API requests.
    • Storage: Stored in client-side memory (JavaScript variable). It is never saved to localStorage, shielding it from XSS extraction.
  2. Refresh Token (Long-Lived):
    • Lifetime: 7 to 30 days.
    • Scope: Used solely to request a new Access Token when the current one expires.
    • Storage: Stored inside a secure, HttpOnly, Secure, SameSite=Strict cookie managed by the browser. JavaScript cannot read this cookie, blocking XSS theft.

The Silent Refresh Flow

When the client-side Access Token expires, the frontend must renew it without disrupting the user.

  1. API Call Fails: The client sends a request. Since the Access Token has expired, the server rejects it with a 401 Unauthorized response.
  2. Interceptor Intercepts: The frontend request client intercepts the 401 response and pauses the request queue.
  3. Token Refresh Request: The client calls a /api/refresh endpoint. The browser automatically attaches the HttpOnly Refresh Token cookie to this request.
  4. Validation and Response: The authentication server validates the Refresh Token, queries its database (checking if the token has been blacklisted), and returns a brand-new short-lived Access Token in the response body.
  5. Retry Queue: The client updates its memory with the new Access Token, releases the request queue, and retries the original failed API request.

The user remains logged in seamlessly, experiencing no visual disruptions or loading screens.

Implementing the Interceptor in Axios

Here is a practical implementation of the silent refresh handler using Axios response interceptors:

import axios from 'axios';

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

// In-memory variable storing the short-lived access token
let accessToken: string | null = null;
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

// Helper to push requests to queue during refresh
function subscribeTokenRefresh(cb: (token: string) => void) {
  refreshSubscribers.push(cb);
}

// Helper to resolve pending queue requests once refreshed
function onRefreshed(token: string) {
  refreshSubscribers.forEach((cb) => cb(token));
  refreshSubscribers = [];
}

// Inject Access Token to outgoing requests
api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// Intercept responses to handle 401 Unauthorized
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const { config, response } = error;
    const originalRequest = config;

    if (response && response.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue the request until token refresh completes
        return new Promise((resolve) => {
          subscribeTokenRefresh((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            resolve(api(originalRequest));
          });
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // Call the refresh endpoint. Cookies are attached automatically by the browser.
        const res = await axios.post('https://api.example.com/api/refresh', {}, {
          withCredentials: true,
        });

        const newAccessToken = res.data.accessToken;
        accessToken = newAccessToken;
        isRefreshing = false;

        // Release queued requests
        onRefreshed(newAccessToken);

        // Retry the original request
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        isRefreshing = false;
        // Refresh token is invalid or expired. Clear session and redirect to login page.
        accessToken = null;
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Security considerations: Token Rotation

To secure this architecture, implement Refresh Token Rotation on your backend:

  • Every time a client submits a Refresh Token, the server invalidates it and returns a new Refresh Token alongside the new Access Token.
  • The backend stores a historical family tree of rotated tokens.
  • If an attacker steals a Refresh Token and tries to reuse it, the backend intercepts the reuse of an invalidated token, flags it as a breach, and invalidates the entire session family tree immediately, locking out both the attacker and the victim (who will simply be prompted to log in again).

Conclusion

Implementing a dual token strategy with silent refresh is the gold standard for secure, user-friendly authentication in SPAs. By storing the short-lived access token in JavaScript memory and the long-lived refresh token in an HttpOnly cookie, you isolate your credentials from XSS scripts while keeping the user logged in without interruption.