Secure JWT Authentication: Refresh Tokens, Silent Refresh, and HttpOnly Cookies

Master robust authentication architecture in Node.js and React by implementing JSON Web Tokens (JWTs), short-lived access tokens, secure HttpOnly cookies for refresh tokens, and silent refresh flows.

As modern web applications transition toward decoupled single-page application (SPA) architectures and RESTful APIs, securing user sessions against cross-site scripting (XSS) and cross-site request forgery (CSRF) attacks has become a primary challenge for backend and frontend engineers.

Storing sensitive authentication tokens in browser `localStorage` or `sessionStorage` leaves applications vulnerable to malicious JavaScript execution via XSS. Conversely, relying solely on standard cookies requires robust CSRF mitigation.

This comprehensive guide explores how to design a production-grade authentication pipeline using short-lived access tokens stored in memory, long-lived refresh tokens secured within `HttpOnly`, `Secure`, and `SameSite` cookies, and automatic silent refresh mechanics.

Access Tokens vs. Refresh Tokens

A secure token-based authentication system decouples session validation from database lookups by utilizing two distinct token types:

• Access Token: A short-lived JWT (typically expiring in 15 minutes) passed in the `Authorization` header (`Bearer `). Because of its short lifespan, even if intercepted, the window of vulnerability is extremely narrow.

• Refresh Token: A long-lived token (expiring in 7 to 30 days) stored exclusively in an `HttpOnly` cookie. It cannot be accessed by client-side JavaScript, rendering it immune to XSS token theft.

Issuing HttpOnly Cookies in Express.js

When a user successfully authenticates with credentials, the backend generates both tokens, sending the access token in the JSON response body and attaching the refresh token to a secure cookie.

TypeScript
Generating JWT tokens and setting HttpOnly refresh token cookies in Express.
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.ACCESS_SECRET || 'access_secret_key';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh_secret_key';

export function generateTokens(userId: string) {
  const accessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
  const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });
  return { accessToken, refreshToken };
}

export async function loginHandler(req: Request, res: Response): Promise<void> {
  const { userId } = req.body;

  const { accessToken, refreshToken } = generateTokens(userId);

  // Store refresh token securely in an HttpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });

  res.status(200).json({
    success: true,
    accessToken,
  });
}

Handling Token Expiration and Automatic Rotation

When an access token expires, client applications must request a new access token without requiring the user to re-authenticate. This background process is known as a **silent refresh**.

TypeScript
Express endpoint for validating refresh tokens and issuing new access tokens.
export async function refreshTokenHandler(req: Request, res: Response): Promise<void> {
  const cookies = req.cookies;
  if (!cookies?.refreshToken) {
    res.status(401).json({ success: false, message: 'Unauthorized: No refresh token provided.' });
    return;
  }

  const refreshToken = cookies.refreshToken;

  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET) as { userId: string };
    const newAccessToken = jwt.sign({ userId: payload.userId }, ACCESS_SECRET, { expiresIn: '15m' });

    res.status(200).json({
      success: true,
      accessToken: newAccessToken,
    });
  } catch (err) {
    res.status(403).json({ success: false, message: 'Forbidden: Invalid or expired refresh token.' });
  }
}

Intercepting 401 Unauthorized Responses to Refresh Tokens

On the React frontend, configure an Axios response interceptor to catch expired access token errors (`401 Unauthorized`), automatically trigger the `/refresh` endpoint, and retry the original failed request.

TypeScript
Configuring Axios interceptors for automatic silent token refresh.
import axios from 'axios';

let accessToken: string | null = null;

export const setAccessToken = (token: string) => {
  accessToken = token;
};

export const api = axios.create({
  baseURL: '/api',
  withCredentials: true,
});

api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

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

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const res = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
        const newAccessToken = res.data.accessToken;

        setAccessToken(newAccessToken);
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

        return api(originalRequest);
      } catch (refreshError) {
        // Refresh token expired; redirect user to login page
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Summary

Implementing secure JWT authentication with refresh tokens, silent refresh flows, and HttpOnly cookies provides an impenetrable defense against XSS token theft.

By keeping short-lived access tokens in memory, storing long-lived refresh tokens in secure server-managed cookies, and automating token rotation via Axios interceptors, developers can deliver seamless, highly secure authentication architectures.