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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Rotating the refresh token on every use limits the window an attacker has with a stolen token.
- 2Storing only a hash of the token means a database leak alone can't be replayed against your auth endpoint.
- 3Revoking the entire token family on a mismatch turns one detected reuse into a full logout of the compromised chain.
Related explainers
javascript
export async function compressImage(file, { maxWidth = 1600, maxHeight = 1600, quality = 0.8, mimeType = 'image/jpeg' } = {}) { const bitmap = await createImageBitmap(file); let { width, height } = bitmap;
Compressing images in the browser with canvas
canvas
image-processing
promises
Intermediate
7 steps
javascript
const express = require('express'); const multer = require('multer'); const path = require('path'); const crypto = require('crypto');
Safe image uploads with Multer in Express
file-upload
multer
validation
Intermediate
7 steps
java
@Configuration @EnableWebSecurity public class ResourceServerConfig {
Configuring a JWT resource server in Spring
oauth2
jwt
authorization
Intermediate
8 steps
ruby
require "openssl" require "json" require "base64"
Building signed session tokens in Ruby
hmac
authentication
cryptography
Intermediate
8 steps
ruby
require "net/http" require "json" require "uri" require "base64"
Paginating an HTTP API with a Ruby enumerator
pagination
http
enumerator
Intermediate
7 steps
javascript
class StarRating { constructor(container, { max = 5, value = 0, onChange } = {}) { this.container = container; this.max = max;
Building an accessible star-rating widget
dom
event-handling
accessibility
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/refresh-token-rotation-in-express-explained-javascript-9208/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.