javascript 57 lines · 9 steps

Refresh token rotation in Express

An Express route that rotates refresh tokens on every use and detects theft by hashing and tracking token families.

Explained by highlit
1const express = require('express');
2const jwt = require('jsonwebtoken');
3const crypto = require('crypto');
4 
5const router = express.Router();
6const { ACCESS_SECRET, REFRESH_SECRET } = process.env;
7 
8const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
9 
10router.post('/auth/refresh', async (req, res) => {
11 const presented = req.cookies.refresh_token;
12 if (!presented) return res.status(401).json({ error: 'missing_refresh_token' });
13 
14 let payload;
15 try {
16 payload = jwt.verify(presented, REFRESH_SECRET);
17 } catch {
18 return res.status(401).json({ error: 'invalid_refresh_token' });
19 }
20 
21 const stored = await RefreshToken.findOne({ jti: payload.jti });
22 
23 if (!stored || stored.revokedAt || stored.tokenHash !== hashToken(presented)) {
24 if (stored) await RefreshToken.revokeFamily(stored.familyId);
25 return res.status(401).json({ error: 'token_reuse_detected' });
26 }
27 
28 const nextJti = crypto.randomUUID();
29 const refreshToken = jwt.sign(
30 { sub: payload.sub, jti: nextJti, familyId: stored.familyId },
31 REFRESH_SECRET,
32 { expiresIn: '30d' }
33 );
34 
35 await stored.updateOne({ revokedAt: new Date(), replacedBy: nextJti });
36 await RefreshToken.create({
37 jti: nextJti,
38 familyId: stored.familyId,
39 userId: payload.sub,
40 tokenHash: hashToken(refreshToken),
41 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
42 });
43 
44 const accessToken = jwt.sign({ sub: payload.sub }, ACCESS_SECRET, { expiresIn: '15m' });
45 
46 res
47 .cookie('refresh_token', refreshToken, {
48 httpOnly: true,
49 secure: true,
50 sameSite: 'strict',
51 path: '/auth/refresh',
52 maxAge: 30 * 24 * 60 * 60 * 1000,
53 })
54 .json({ accessToken, expiresIn: 900 });
55});
56 
57module.exports = router;
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Rotating the refresh token on every use limits the window an attacker has with a stolen token.
  2. 2Storing only a hash of the token means a database leak alone can't be replayed against your auth endpoint.
  3. 3Revoking the entire token family on a mismatch turns one detected reuse into a full logout of the compromised chain.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Refresh token rotation in Express — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code