JWT Dual Token Pattern: Access + Refresh Token in Production
January 2026 · ~12 min read · Part 2 of the JWT series
In Part 1: How JWT Works we covered what a JWT is and how signatures are verified. In production, however, a single-token design is almost guaranteed to fail. This post walks through the production-grade answer — the Access Token + Refresh Token dual-token pattern — and shows how to reconcile two opposing forces: how to revoke a token and how to never bother the user.
The single-token dilemma
A JWT is, by design, stateless on the server. Once issued, you cannot revoke it. You're stuck relying on the exp claim. But that creates a trap:
- Short
exp(15 min): the user gets kicked out of a long form every quarter hour. Support tickets pile up. - Long
exp(30 days): a leaked token is valid for a month. You cannot revoke it. Blast radius is huge.
The fix is separation of concerns: one short-lived token for everyday API calls (cheap to lose), and one long-lived token whose only job is to mint new short-lived ones (small attack surface).
The dual-token model
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | 5–30 minutes | 7–30 days |
| Purpose | Authorize API calls | Only mints new access tokens |
| Transport | Authorization: Bearer … | httpOnly cookie (auto-sent) |
| Storage | Memory (best) / sessionStorage | httpOnly + Secure + SameSite cookie |
⚠ Never store any token in localStorage — any XSS payload can read it. In-memory is the safest place for the access token; sessionStorage is an acceptable compromise if you must persist across reloads.
The login flow, end to end
Browser Server
| |
|--- POST /api/login --------> |
| { email, password } |--- verify bcrypt
| |
|<-- 200 ------------------------|
| { access (15m) } |
| Set-Cookie: refresh=... | (HttpOnly, Secure, SameSite=Strict, 7d)
| |
|--- GET /api/profile ---------> |
| Authorization: Bearer <access>|
| |
|<-- 200 { user } ---------------|
| |
| … 15 min later … |
| |
|--- GET /api/refresh ---------> |
| (cookie sent automatically) |--- verify refresh, ROTATE
| |
|<-- 200 ------------------------|
| { access (new) } |
| Set-Cookie: refresh=<new> | (old refresh now dead)
| |
| … 7 days later … |
|--- GET /api/refresh ---------> |
|<-- 401 (refresh expired) ------|
| → redirect to /login |
Node.js implementation
Login endpoint
// login.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const ACCESS_SECRET = process.env.ACCESS_SECRET; // 32+ random bytes
const REFRESH_SECRET = process.env.REFRESH_SECRET; // DIFFERENT secret
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user || !await bcrypt.compare(req.body.password, user.hash))
return res.status(401).json({ error: 'Invalid credentials' });
const access = jwt.sign(
{ sub: user._id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refresh = jwt.sign(
{ sub: user._id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refresh', refresh, {
httpOnly: true, // JS can't read it → mitigates XSS
secure: true, // HTTPS only → blocks MITM
sameSite: 'strict',// not sent on cross-site requests → blocks CSRF
path: '/api/refresh', // limit scope
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ access, user: { id: user._id, name: user.name } });
});
Refresh Token Rotation (with replay detection)
Definition: every time a refresh token is exchanged, the server issues a new refresh token and the old one becomes invalid immediately.
Why this stops replay: if an attacker steals a refresh token and tries to use it twice — once the legitimate client did, once the attacker does — the second use matches an already-rotated token. The server detects this, assumes the original was compromised, and invalidates the entire token family, forcing the user (and the attacker) to log in again.
// In production use Redis, not an in-memory Set
const usedRefreshTokens = new Set();
app.post('/api/refresh', async (req, res) => {
const oldRefresh = req.cookies.refresh;
if (!oldRefresh) return res.status(401).end();
// Replay detected → kill the entire family
if (usedRefreshTokens.has(oldRefresh)) {
usedRefreshTokens.clear();
res.clearCookie('refresh');
return res.status(401).json({ error: 'Token reuse detected — session terminated' });
}
try {
const payload = jwt.verify(oldRefresh, REFRESH_SECRET);
usedRefreshTokens.add(oldRefresh);
const newAccess = jwt.sign(
{ sub: payload.sub, role: payload.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const newRefresh = jwt.sign(
{ sub: payload.sub },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refresh', newRefresh, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/api/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ access: newAccess });
} catch (err) {
res.status(401).end();
}
});
In production, store usedRefreshTokens in Redis with a TTL equal to the refresh token's lifetime, so old entries don't accumulate forever.
4 revocation strategies compared
| Strategy | How it works | Verdict |
|---|---|---|
| Blacklist | Store revoked jti in Redis, check on every request | Simple, scales horizontally. Adds a Redis lookup per request. |
| Whitelist | Deny by default, only allow tokens in a session store | Negates the point of stateless JWT. Rarely used. |
Short exp + rotation | Access auto-expires, refresh is rotatable and revocable | Recommended for most apps. |
| tokenVersion | User row has a tokenVersion int; bump it to invalidate every issued token | Coarse-grained, but ideal for "log out everywhere" / password change. |
Blacklist with jti + Redis
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
// Sign with a jti
const access = jwt.sign(
{ sub: user._id },
ACCESS_SECRET,
{ expiresIn: '15m', jwtid: crypto.randomUUID() }
);
// Explicit revocation (logout / password change)
app.post('/api/logout', async (req, res) => {
const jti = req.user.jti;
const exp = req.user.exp;
const ttl = Math.max(1, exp - Math.floor(Date.now() / 1000));
await redis.set(`revoked:${jti}`, '1', 'EX', ttl); // auto-cleans at exp
res.clearCookie('refresh');
res.status(204).end();
});
// Auth middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.slice(7);
if (!token) return res.status(401).end();
let payload;
try { payload = jwt.verify(token, ACCESS_SECRET); }
catch { return res.status(401).end(); }
if (await redis.get(`revoked:${payload.jti}`))
return res.status(401).json({ error: 'Token revoked' });
req.user = payload;
next();
}
5 production pitfalls (the ones that actually bite)
- Storing the access token in
localStorage— a single XSS payload canlocalStorage.getItem('access')and ship it to an attacker's server. Keep the access token in memory; persist only the httpOnly refresh cookie. - Sending the access token as a cookie — defeats the purpose of the dual-token model: you lose CSRF protection and make rotation awkward. Use
Authorization: Bearerfor the access token, cookie for the refresh. - Not pinning the
algorithmsoption on verify — historical CVEs (CVE-2015-9235, CVE-2018-0114) hinge on servers acceptingalg: noneor treating an RS256 public key as an HS256 secret. Always pass{ algorithms: ['RS256'] }explicitly. - Reusing one secret for both access and refresh — one leak = total compromise. Use two independent, randomly generated secrets (or one RSA/EC keypair for access, one HMAC secret for refresh).
- Skipping rotation — a refresh token with a 30-day lifetime and no rotation is, functionally, a 30-day bearer credential. Pair every refresh exchange with rotation and store the previous token in a one-time-use set.
🛠 Try it yourself
Paste any JWT into the DevToolbox JWT Decoder to see the sub, exp, and jti claims exposed in the payload — exactly the fields you'll be reading server-side in the code above.
FAQ
Q1: Why is the access token short and the refresh token long?
A: It's the security vs. UX trade-off. The access token is sent on every API request — a large attack surface — so it must be short-lived. The refresh token is sent on exactly one endpoint, far less frequently, so it can be long-lived and afford to be heavily protected (rotation, device binding, server-side checks).
Q2: Can I just store refresh tokens in Redis as a whitelist?
A: Yes — and for high-security apps (banking, healthcare) you arguably should. It costs you a Redis lookup per refresh and persistence guarantees (RDB + AOF, or a managed Redis with persistence enabled). For most apps, short TTL + rotation is enough; the whitelist is the next tier up.
Q3: How does the frontend handle silent refresh?
A: An Axios / fetch interceptor catches 401 responses, calls /api/refresh (cookie travels automatically), stores the new access token in memory, and replays the original request. With multiple in-flight 401s, queue them behind a single refresh promise to avoid a thundering herd of refresh calls.
Q4: What if the access token is still in flight when I revoke it?
A: The jti blacklist kills the next request that hits the middleware, which returns 401 and the frontend redirects to /login. Worst-case latency equals the remaining access token lifetime — which is exactly why you keep it short.
Q5: How do I keep the refresh endpoint itself from being abused?
A: Three layers: (1) rate-limit per IP and per userId (e.g. 10/min); (2) bind the refresh token to a fingerprint — hash of User-Agent + a first-party cookie or the IP's /24; mismatch → reject. (3) rotation means a single replay attempt destroys the chain and forces re-authentication, so attackers can't quietly farm new tokens.
Wrapping up
The dual-token pattern is fundamentally about separation of duties: the access token does the risky, high-frequency work and is disposable; the refresh token is the long-lived trust anchor and is heavily guarded. Add rotation (to kill replay), an httpOnly + Secure + SameSite=Strict cookie (to keep the refresh token out of JS and away from cross-site requests), and a jti blacklist (to revoke on demand), and you have a setup that survives a real incident.
Next in the series: OAuth 2.0 + JWT — how JWTs fit into third-party login (Google, GitHub, Auth0) and which claims you actually need to trust.
📚 Continue the series
- JWT Fundamentals: Header / Payload / Signature Explained (read this first)
- Base64 Encoding: The Complete Guide (JWT uses Base64URL under the hood)