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
• 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.
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**.
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.
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.