Skip to content
All articles
Technical21 May 2026

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:

TokenLifetimePurpose
Access15 minutesSent on every request; proves who you are
Refresh7 daysUsed 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.

Book a consultation

General information, not professional advice — see our legal notice.