๐ Token Refresh Strategies
A 15-minute access token is great for security and terrible for users โ nobody wants to log in four times an hour. The fix is a two-token system: a short-lived access token for day-to-day requests and a long-lived refresh token whose only job is to quietly mint new access tokens. This lesson shows how to build that safely, add rotation and revocation, and store the tokens where attackers can't reach them.
Week 9 · Day 2 (Tuesday: JWT Authentication) · Lecture 3
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain the security/UX tradeoff that motivates short access tokens plus long refresh tokens
- Build a
/refreshendpoint that issues a new access token from a valid refresh token - Implement refresh-token rotation and detect reuse of an old token
- Maintain a revocation list so logout and theft truly end a session
- Choose token storage deliberately, weighing the XSS risk of
localStorageagainst the CSRF risk of cookies - Wire a client interceptor that refreshes transparently on a 401
Estimated Time: 70 minutes
Project: Extend the auth API with refresh, rotation, and logout that revokes.
In This Lesson
The Expiry Dilemma
Token expiry is a security feature, not a bug โ the shorter a token's life, the smaller the window an attacker has if they steal it. But there's a tension:
- Short-lived tokens (5โ15 min) are safe but annoying โ the user keeps getting logged out.
- Long-lived tokens (days or weeks) are convenient but dangerous โ a stolen one grants access for a long time, and JWTs are hard to revoke.
๐จ Analogy: key card and passport
A hotel gives you a room key card that stops working every day (the access token) and keeps your passport reference at the front desk (the refresh token, tracked server-side). When the card expires you show ID at the desk and get a fresh card โ no need to re-book. If the card is stolen it's useless within a day; if something's wrong with your ID, the desk can flag it so no more cards are issued.
The two-token pattern gives you both halves: a short access token for safety, and a refresh token โ tracked in your database โ that can be revoked the moment anything looks wrong.
The Two-Token Pattern
At login the server issues both tokens. The access token is stateless and never stored server-side; the refresh token is stored (ideally as a hash) so you can look it up and revoke it.
const jwt = require('jsonwebtoken');
function signAccessToken(user) {
return jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_ACCESS_SECRET, {
expiresIn: '15m',
algorithm: 'HS256',
});
}
function signRefreshToken(user, tokenId) {
// jti = a unique id so we can track and revoke this specific token
return jwt.sign({ sub: user.id, jti: tokenId }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '7d',
algorithm: 'HS256',
});
}
๐ก Use two different secrets
Sign access and refresh tokens with separate secrets (JWT_ACCESS_SECRET and JWT_REFRESH_SECRET). If one leaks, the other class of token is still safe, and it makes it impossible to accidentally accept a refresh token where an access token belongs.
The Refresh Endpoint
The /refresh route does three checks before issuing a new access token: the refresh token's signature must verify, it must still exist in the database (not revoked), and โ implicitly via verify โ it must not be expired.
// A stand-in store: maps a token id (jti) to its record.
// In production this is a database table or a Redis set.
const refreshStore = new Map(); // jti -> { userId, expiresAt }
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token required' });
}
let payload;
try {
// 1. Verify signature + expiry.
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256'],
});
} catch {
return res.status(403).json({ message: 'Invalid refresh token' });
}
// 2. Confirm it is still active (not revoked / logged out).
const record = refreshStore.get(payload.jti);
if (!record) {
return res.status(403).json({ message: 'Refresh token revoked' });
}
// 3. Issue a new short access token.
const user = { id: payload.sub, role: 'user' };
const accessToken = signAccessToken(user);
res.json({ accessToken });
});
โ ๏ธ Signature alone is not enough
A refresh token can be signed-valid yet revoked โ the user logged out, or you detected theft. That's exactly why refresh tokens are tracked server-side while access tokens aren't: the database lookup is your chance to say "no" even though the signature checks out. Skip that lookup and logout becomes meaningless.
Refresh-Token Rotation
The basic endpoint above keeps the same refresh token alive for a week โ a stolen one works the whole time. Rotation hardens this: every time a refresh token is used, it's invalidated and a brand-new one is issued. Now a stolen token is only useful until the legitimate user next refreshes.
Better still, rotation gives you theft detection. If an already-used (rotated-out) token is presented again, something is wrong โ either an attacker has an old copy or the real user does. The safe response is to revoke the entire token chain and force a fresh login.
and unused?"} B -->|Yes| C["Invalidate A, issue token B + new access token"] B -->|"No โ A was already used"| D["Reuse detected!"] D --> E["Revoke the whole token family"] E --> F["Force re-login"]
const crypto = require('crypto');
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
let payload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256'],
});
} catch {
return res.status(403).json({ message: 'Invalid refresh token' });
}
const record = refreshStore.get(payload.jti);
// Reuse of a rotated-out token => probable theft. Nuke the family.
if (!record) {
revokeFamily(payload.sub); // remove every refresh token for this user
return res.status(403).json({ message: 'Token reuse detected โ please log in again' });
}
// Rotate: delete the old, mint a new refresh token.
refreshStore.delete(payload.jti);
const newJti = crypto.randomUUID();
refreshStore.set(newJti, { userId: payload.sub, expiresAt: Date.now() + 7 * 864e5 });
const user = { id: payload.sub, role: 'user' };
res.json({
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user, newJti),
});
});
โ Why rotation is the modern default
Rotation shrinks the theft window to a single refresh cycle and turns a silent compromise into a loud, detectable event. It's the approach recommended by the OAuth 2.0 security guidance and used by services like Auth0. For any app holding real user data, rotation should be your baseline, not an add-on.
Revocation & Logout
Because access tokens are stateless, "logout" can't invalidate them directly โ they simply expire in a few minutes. What logout can do is delete the refresh token so no new access tokens are ever issued for that session. That's why real logout is a server call, not just clearing client storage.
// Remove one refresh token โ an ordinary logout.
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
try {
const { jti } = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256'],
});
refreshStore.delete(jti); // this session can no longer refresh
} catch {
/* already invalid โ nothing to revoke */
}
res.json({ message: 'Logged out' });
});
// Remove every refresh token for a user โ "log out everywhere"
// and the theft response used by revokeFamily above.
function revokeFamily(userId) {
for (const [jti, record] of refreshStore) {
if (record.userId === userId) refreshStore.delete(jti);
}
}
๐ก What about revoking access tokens immediately?
If you truly need instant kill-switch behaviour on access tokens (say, banning a user right now), keep a small deny list of blocked jti values or user ids that your auth middleware checks. It reintroduces a lookup โ the cost of instant revocation โ so most apps rely on short expiry instead and accept a few-minute delay.
Where to Store Tokens
Storage is where most JWT security is won or lost. There is no single "correct" place โ each option trades one attack surface for another. The two threats to weigh are XSS (malicious JavaScript running in your page can read anything JS can read) and CSRF (a hostile site tricks the browser into sending your cookies).
| Location | Exposed to XSS? | Exposed to CSRF? | Good for |
|---|---|---|---|
localStorage | Yes โ any script can read it | No (not auto-sent) | Quick demos only |
| In-memory JS variable | Harder โ gone on refresh | No | Access tokens |
| httpOnly cookie | No โ JS can't read it | Yes โ needs mitigation | Refresh tokens |
โ ๏ธ The localStorage trap
Storing a token in localStorage is convenient and survives refreshes โ but it's readable by any JavaScript on the page, including a script injected through an XSS hole or a compromised npm dependency. One XSS bug and every user's token walks out the door. It's fine for a throwaway demo, wrong for anything with real accounts.
A widely recommended pattern for web apps: keep the access token in a JavaScript variable in memory (short-lived, unreachable after a refresh), and keep the refresh token in an httpOnly, Secure, SameSite cookie so no script can read it. httpOnly closes the XSS door on the long-lived token; SameSite closes most of the CSRF door.
// Setting the refresh token as a locked-down cookie at login.
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript cannot read it โ blocks XSS theft
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // not sent on cross-site requests โ blocks most CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/refresh', // the browser only sends it to the refresh route
});
๐ก If you use cookies, add CSRF defence
Cookies are attached automatically by the browser, which is what makes them CSRF-prone. SameSite=Strict handles the common cases; for extra safety add the double-submit CSRF token pattern or verify the Origin header. Native mobile apps sidestep all of this by using secure OS storage (iOS Keychain, Android Keystore) instead of cookies.
Transparent Client Refresh
Users shouldn't see the refresh happen. The trick is an HTTP interceptor: when a request comes back 401, automatically call /refresh, get a new access token, and retry the original request once. Here it is with an axios response interceptor.
import axios from 'axios';
const api = axios.create({ baseURL: '/api', withCredentials: true });
let accessToken = null; // kept in memory only
api.interceptors.request.use((config) => {
if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
// Only try to refresh once, and only on a 401.
if (error.response?.status === 401 && !original._retried) {
original._retried = true;
try {
// The refresh cookie is sent automatically (withCredentials).
const { data } = await axios.post('/api/refresh', {}, { withCredentials: true });
accessToken = data.accessToken; // update in-memory token
original.headers.Authorization = `Bearer ${accessToken}`;
return api(original); // retry the original request
} catch (refreshErr) {
window.location.href = '/login'; // refresh failed โ re-login
return Promise.reject(refreshErr);
}
}
return Promise.reject(error);
}
);
โ ๏ธ Guard against refresh loops and stampedes
The _retried flag stops an endless loop when refresh itself returns 401. In apps that fire many requests at once, also queue concurrent 401s so they wait for a single in-flight refresh instead of hammering /refresh in parallel (which, with rotation on, would look like token reuse and log everyone out).
Practice & Quiz
๐๏ธ Exercise 1: Add "log out everywhere"
Goal: Add a protected POST /logout-all route that revokes every refresh token for the current user, so all their devices must log in again. Assume requireAuth has set req.user.sub.
๐ก Hint
You already wrote revokeFamily(userId) for theft detection โ reuse it here with req.user.sub.
โ Solution
app.post('/logout-all', requireAuth, (req, res) => {
revokeFamily(req.user.sub); // delete every refresh token for this user
res.json({ message: 'Logged out on all devices' });
});
Existing access tokens still work until they expire (a few minutes), but no device can obtain a new one โ the sessions are effectively over.
๐๏ธ Exercise 2: Reason about a stolen refresh token
Goal: With rotation enabled, an attacker steals refresh token A. The real user then refreshes (getting token B) before the attacker acts. Walk through what happens when the attacker finally presents A.
โ Solution
- The user's refresh rotated A out โ it was deleted from the store and replaced by B.
- The attacker presents A. Its signature verifies, but the store lookup for A's
jtimisses. - A missing-but-signed token means reuse: the server calls
revokeFamily(userId), deleting B too. - Both the attacker and the real user are forced to log in again โ a small annoyance for the user, a dead end for the attacker.
The theft became a detectable event instead of silent, long-term access. That's the whole point of rotation.
๐ฏ Quick Quiz
Question 1: Why can't a plain logout invalidate an access token immediately?
Question 2: What does refresh-token rotation primarily add over a static refresh token?
Question 3: Which storage best protects a long-lived refresh token from XSS?
Best Practices & Pitfalls
โ Do
- Pair a short access token (~15 min) with a longer refresh token (daysโweeks)
- Track refresh tokens server-side and store them hashed
- Rotate refresh tokens on every use and revoke the family on reuse
- Make logout a server call that deletes the refresh token
- Keep the access token in memory and the refresh token in an httpOnly, Secure, SameSite cookie
- Use separate secrets for access and refresh tokens
โ Don't
- Don't keep long-lived tokens in
localStoragefor real accounts - Don't trust a refresh token on signature alone โ always check the store
- Don't fire parallel refreshes; queue them behind one in-flight request
- Don't forget CSRF defence when the refresh token lives in a cookie
- Don't treat clearing client storage as a real logout
โ ๏ธ Rotation without reuse detection is half a feature
Issuing a new refresh token each time is only useful if you also notice when an old one comes back. Without the "missing-but-signed means reuse" check and a family revoke, rotation just moves tokens around without adding real theft protection.
Summary
๐ Key Takeaways
- Solve the expiry dilemma with two tokens: a short stateless access token and a long, tracked refresh token
- The
/refreshendpoint checks signature, expiry, and a server-side record before issuing a new access token - Rotation shrinks the theft window and, with reuse detection, turns compromise into a loud, revocable event
- Logout and revocation work by deleting the refresh token server-side, since access tokens can't be recalled
- Store tokens deliberately: access in memory, refresh in an httpOnly + SameSite cookie, never long-lived in
localStorage
๐ Additional Resources
- OWASP โ JSON Web Token Cheat Sheet
- RFC 6749 ยง1.5 โ refresh tokens in OAuth 2.0
- jsonwebtoken โ signing and verifying with separate secrets
- MDN โ the SameSite cookie attribute
๐ What's Next?
JWTs aren't the only way to keep a user logged in. The next lesson, Express Sessions, revisits the stateful, server-side approach โ cookies backed by a session store โ so you can choose the right tool for each app instead of reaching for tokens by reflex.
๐ Sessions that stay secure!
Short access tokens, rotating refresh tokens, real revocation, safe storage โ you can now keep users logged in the professional way.