Security is a foundational pillar of production web applications. A common mistake developers make is storing JSON Web Tokens (JWTs) in localStorage or sessionStorage. This exposes the application to Cross-Site Scripting (XSS) token theft.

The Two-Token Authentication Pattern

To balance security and user experience, production systems utilize a two-token architecture:

  1. Access Token (Short-lived): Valid for 15 minutes. Transmitted in the HTTP Authorization: Bearer <token> header for rapid API verification.
  2. Refresh Token (Long-lived): Valid for 7–30 days. Stored inside a secure, httpOnly, SameSite=Strict cookie.

Step 1: Generating Tokens in Express

import jwt from "jsonwebtoken";

export const generateTokens = (userId: string, role: string) => {
  const accessToken = jwt.sign(
    { userId, role },
    process.env.ACCESS_TOKEN_SECRET!,
    { expiresIn: "15m" }
  );

  const refreshToken = jwt.sign(
    { userId },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: "7d" }
  );

  return { accessToken, refreshToken };
};

Step 2: Setting HTTP-Only Refresh Cookies

When a user logs in, send the short-lived access token in the JSON response body while attaching the refresh token as a secure cookie:

res.cookie("refreshToken", refreshToken, {
  httpOnly: true, // Prevents client-side JS read access
  secure: process.env.NODE_ENV === "production", // Transmit over HTTPS only
  sameSite: "strict", // Prevents CSRF attacks
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

res.status(200).json({
  success: true,
  accessToken,
  user: { id: user._id, email: user.email, role: user.role }
});

Step 3: Axios Silent Refresh Interceptor on Frontend

On the frontend, configure an Axios response interceptor to intercept 401 Unauthorized errors and silently request a fresh access token using the /api/auth/refresh endpoint:

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        const { data } = await axios.post("/api/auth/refresh", {}, { withCredentials: true });
        setAccessToken(data.accessToken);
        originalRequest.headers["Authorization"] = `Bearer ${data.accessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        // Token expired or invalid -> redirect to login
        window.location.href = "/login";
      }
    }
    return Promise.reject(error);
  }
);

Summary

By isolating long-lived credentials inside HTTP-only browser cookies and keeping short-lived access tokens strictly in memory, you eliminate XSS token theft vectors while preserving seamless user session persistence.