Lesson 12 / 25
Access Tokens, Refresh Tokens and Browser Storage
Short-lived access, rotating refresh, careful storage.
Short access, rotating refresh
A self-contained JWT cannot easily be revoked before exp, so keep access tokens short-lived (minutes) and pair them with a refresh token that the client exchanges for new access tokens. Refresh tokens are long-lived and powerful: store them server-side as revocable records, and use refresh token rotation (each use returns a new refresh token and invalidates the old one; reuse of an old one signals theft, so revoke the whole family). In browsers, avoid localStorage and sessionStorage for long-lived tokens: any XSS can read them. Safer options are an HttpOnly, Secure, SameSite cookie (with CSRF protection), keeping access tokens only in memory, or a backend-for-frontend (BFF) that holds tokens server-side and gives the browser only a session cookie. For many first-party web apps, a plain server-side session is simpler than JWTs.
A rotating refresh endpoint
Refresh token in an HttpOnly cookie scoped to one path; access token returned for in-memory use.
app.post('/auth/refresh', requireCsrf, async (req, res) => {
const presented = req.cookies['__Secure-rt'];
const record = presented && (await db.refreshTokens.findByHash(sha256(presented)));
if (!record || record.expiresAt < new Date()) return res.status(401).end();
if (record.usedAt) { // reuse => likely stolen
await db.refreshTokens.revokeFamily(record.familyId);
audit('refresh_reuse_detected', { userId: record.userId });
return res.status(401).end();
}
await db.refreshTokens.markUsed(record.id);
const next = crypto.randomBytes(32).toString('base64url');
await db.refreshTokens.insert({ familyId: record.familyId, userId: record.userId,
tokenHash: sha256(next), expiresAt: addDays(new Date(), 14) });
res.cookie('__Secure-rt', next, { httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh', maxAge: 14 * 24 * 3600 * 1000 });
res.json({ accessToken: await signAccessToken(record.userId, '10m') });
});Do you need JWTs at all?
If one web app talks to its own backend, an opaque session cookie gives instant revocation with less machinery. JWT access tokens earn their place when many independent services must verify identity without a shared session store.
Quick check: Why is localStorage a poor place for a long-lived refresh token?
- It is cleared on every page load
- Any successful XSS can read it and send it to an attacker
- It cannot store strings longer than 64 characters
- Browsers send it automatically with every request
Answer
Any successful XSS can read it and send it to an attacker — Unlike HttpOnly cookies, localStorage is fully readable by scripts on the page.