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