Mahbubul Alam.
Backend

JWT Authentication Done Right: Access Tokens, Refresh Tokens, and Revocation

Jul 20262 min read
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 tokenVersion server-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.

#jwt#auth#security