Refresh Tokens & Token Rotation
A stateless JWT is fast to verify but impossible to revoke before it expires, which forces an uncomfortable trade-off: short lifetimes annoy users with constant logins, long lifetimes leave a stolen token usable for hours. The refresh-token pattern resolves this by splitting the problem in two. A short-lived access token (minutes) authorizes API calls, while a long-lived refresh token (days to weeks) does nothing but mint new access tokens. This page shows how to issue both, store the refresh token in an httpOnly cookie, rotate it on every use, and revoke it server-side.
The two-token model
The access token is a normal signed JWT carrying the user’s identity and roles. It expires quickly, so even if it leaks the window of abuse is small, and it never touches a database on the hot path. The refresh token is the opposite: it is long-lived, it is opaque (or a JWT you track), and crucially it is stored server-side so you can revoke it. The client never sends the refresh token to your API routes — only to the dedicated /auth/refresh endpoint.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 5–15 minutes | 7–30 days |
| Sent on | Every API request (Authorization header) | Only to /auth/refresh (cookie) |
| Storage (client) | Memory / variable | httpOnly, secure cookie |
| Storage (server) | None (stateless) | Database / Redis row |
| Revocable | No (wait for expiry) | Yes (delete the row) |
Issuing tokens at login
After verifying the password with bcrypt, sign a short access token and create a refresh token. Store a hash of the refresh token server-side — never the raw value — so a leaked database does not hand an attacker live credentials. The raw refresh token goes into an httpOnly cookie scoped to the refresh path.
// routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const db = require('../db');
const router = express.Router();
const signAccessToken = (user) =>
jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '10m',
});
const hashToken = (token) =>
crypto.createHash('sha256').update(token).digest('hex');
const REFRESH_TTL_MS = 1000 * 60 * 60 * 24 * 14; // 14 days
async function issueRefreshToken(userId, res) {
const token = crypto.randomBytes(40).toString('hex'); // opaque, unguessable
await db.saveRefreshToken({
userId,
tokenHash: hashToken(token),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
});
res.cookie('refresh_token', token, {
httpOnly: true, // unreadable to JS (XSS)
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'strict', // not sent cross-site (CSRF)
path: '/auth', // only sent to /auth/* routes
maxAge: REFRESH_TTL_MS,
});
return token;
}
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
await issueRefreshToken(user.id, res);
res.json({ accessToken: signAccessToken(user) });
} catch (err) {
next(err);
}
});
module.exports = { router, signAccessToken, hashToken, issueRefreshToken, REFRESH_TTL_MS };
The response body carries only the access token; the refresh token rides in the Set-Cookie header and the browser stores it automatically.
HTTP/1.1 200 OK
Set-Cookie: refresh_token=9f2c...e1; Path=/auth; HttpOnly; Secure; SameSite=Strict; Max-Age=1209600
Content-Type: application/json; charset=utf-8
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
The refresh endpoint with rotation
When the access token expires, the client calls /auth/refresh. The cookie is sent automatically. You look up the stored hash, and on success you rotate: delete the old refresh token and issue a brand-new one alongside a fresh access token. Rotation means each refresh token is single-use, which lets you detect theft (covered below).
const { signAccessToken, hashToken, issueRefreshToken } = require('./auth');
router.post('/refresh', async (req, res, next) => {
try {
const presented = req.cookies.refresh_token;
if (!presented) return res.status(401).json({ error: 'No refresh token' });
const stored = await db.findRefreshToken(hashToken(presented));
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Rotate: invalidate the used token, then issue a new pair
await db.deleteRefreshToken(stored.tokenHash);
const user = await db.findUserById(stored.userId);
await issueRefreshToken(user.id, res);
res.json({ accessToken: signAccessToken(user) });
} catch (err) {
next(err);
}
});
This route needs
cookie-parser(app.use(require('cookie-parser')())) soreq.cookiesis populated. In Express 5, a rejected async handler is forwarded to error middleware automatically; in Express 4 you must pass it tonext(err)as shown.
Detecting reuse and revoking
Because rotation makes every refresh token single-use, a token that was already rotated should never appear again. If it does, either the network retried or — more likely — the token was stolen and is being replayed. The safe response is to treat reuse as a breach and revoke the entire token family for that user, forcing a fresh login everywhere.
async function rotateOrRevoke(presentedHash) {
const stored = await db.findRefreshToken(presentedHash);
if (!stored) {
// Hash was valid-looking but already consumed → reuse attempt
const family = await db.findFamilyByConsumedHash(presentedHash);
if (family) await db.revokeAllForUser(family.userId); // nuke all sessions
return null;
}
return stored;
}
Logout is just a targeted revocation: delete the presented refresh token’s row and clear the cookie.
router.post('/logout', async (req, res, next) => {
try {
const presented = req.cookies.refresh_token;
if (presented) await db.deleteRefreshToken(hashToken(presented));
res.clearCookie('refresh_token', { path: '/auth' });
res.json({ message: 'Logged out' });
} catch (err) {
next(err);
}
});
For a “log out all devices” feature, call db.revokeAllForUser(userId) to drop every stored refresh token. The next /auth/refresh from any device fails, and once their short access token expires (minutes later) they are fully logged out.
Best Practices
- Keep access tokens short (5–15 minutes) so a leaked one is useless quickly, and let the refresh flow handle longevity.
- Store only a hash of the refresh token server-side; treat the raw value like a password.
- Always set
httpOnly,secure,sameSite, and a narrowpathon the refresh cookie so it is never exposed to JavaScript or sent cross-site. - Rotate the refresh token on every use and treat reuse of a consumed token as theft — revoke the whole family.
- Give refresh tokens an absolute
expiresAtand prune expired rows so the store does not grow unbounded. - Never put the refresh token in
localStorageor the response body; cookies scoped to the refresh path are the only place it belongs.