JWT Authentication Done Right: Access Tokens, Refresh Tokens, and Revocation
The two mistakes almost every JWT tutorial makes
First: storing the token in localStorage, which is readable by any script running on the page — meaning a single XSS vulnerability anywhere in your frontend dependency tree hands an attacker a valid, long-lived session. Second: issuing a single long-lived token with no way to invalidate it before it expires — if it leaks, your only remedy is waiting.
The pattern: short-lived access, httpOnly refresh
Access tokens are short-lived (I use 15 minutes) JWTs, returned in the response body and held in memory on the client — never persisted to storage the JS runtime can read. Refresh tokens are longer-lived (7 days), issued as an httpOnly, secure, sameSite cookie that JavaScript literally cannot access, only the browser sending it automatically on requests to the auth endpoint.
res.cookie("refreshToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/api/v1/auth", // only sent to the refresh endpoint, nowhere else
maxAge: 7 * 24 * 60 * 60 * 1000,
});
An XSS bug can still steal the in-memory access token — but it's worth 15 minutes, not 7 days, and it can't touch the refresh cookie at all. That asymmetry is the entire point of the split.
Revocation without a database hit on every request
The classic tension: checking a token blocklist on every request defeats the performance benefit of stateless JWTs, but a purely stateless token can't be revoked before it expires. The middle ground I use is a tokenVersion counter on the user record, embedded as a claim in every token issued:
// On logout, or "log out everywhere":
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
// On refresh, reject if the token's version is stale:
if (decoded.tokenVersion !== user.tokenVersion) {
throw new UnauthorizedError("Session revoked");
}
This makes refresh tokens instantly revocable (one write, checked only at refresh time, not on every single request) while access tokens still self-expire naturally within their short window without any lookup at all.
What "logout" actually needs to do
- Clear the refresh cookie client-side
- Bump
tokenVersionserver-side, so any refresh token issued before this moment stops working - Not bother invalidating the current access token — it'll expire in minutes regardless, and the complexity of an access-token blocklist isn't worth it for that short a window
The silent-refresh flow that ties it together
On page load, the client attempts a refresh immediately, before rendering any protected UI — the httpOnly cookie is sent automatically if it exists, and either a fresh access token comes back (user stays logged in silently) or it fails (redirect to login). Combined with an axios/fetch interceptor that catches a 401, retries the refresh once, and replays the original request, the user never sees a login prompt mid-session unless their refresh token has genuinely expired or been revoked.