JWT auth that won’t get you hacked
By Miroslav Tadej
Most "JWT auth" tutorials stop at the part that gets you hacked. They hand you a token,
tell you to store it in localStorage, and move on. Here is how session auth actually
works when you care about security — and how this very site does it.
Why not localStorage
A token in localStorage is readable by any JavaScript running on the page. One cross-site
scripting (XSS) bug — a bad dependency, a reflected input — and the attacker reads the token
and is the user. No amount of token cleverness fixes a storage choice that hands the key
to anyone who can run a script.
The fix is to put the token somewhere JavaScript cannot read it: an httpOnly cookie.
res.cookie('access_token', token, {
httpOnly: true, // JS cannot read it
sameSite: 'lax', // not sent on cross-site requests
secure: isProd, // HTTPS only in production
maxAge: 15 * 60_000 // 15 minutes
});
Two tokens, two jobs
A single long-lived token is a liability: if it leaks, it is valid for its whole lifetime. So we split the job in two:
| Token | Lifetime | Purpose |
|---|---|---|
| Access | 15 minutes | Sent on every request; proves who you are |
| Refresh | 7 days | Used only to mint a new access token |
The access token is short-lived, so a leak is a 15-minute problem. The refresh token is long-lived but rarely transmitted and — crucially — revocable.
Rotation: the part most people skip
Every time a refresh token is used, we revoke it and issue a brand-new one. We store
only a hash of each refresh token, keyed by a unique jti:
-- on refresh:
UPDATE refresh_tokens SET revoked_at = now() WHERE jti = $1;
-- then insert a fresh row for the new token
Why bother? Because rotation makes stolen refresh tokens detectable and useless. If an attacker steals a refresh token and uses it, the legitimate user's next refresh fails — the token was already rotated — and the whole family can be invalidated. A stolen token without rotation is a skeleton key; with rotation it is a tripwire.
The silent refresh
The client never sees any of this. An Axios interceptor catches a 401, calls
/auth/refresh exactly once, and replays the original request:
if (status === 401 && !original._retry) {
original._retry = true;
await refreshOnce(); // deduped — one refresh for N concurrent 401s
return api(original); // retry transparently
}
The user stays logged in for seven days, every request is authorised by a token that expires in fifteen minutes, and nothing sensitive is ever readable by JavaScript. That is the whole game.
General information, not professional advice — see our legal notice.