Authentication & Sessions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of
Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions were checked against the official
documentation and the npm registry on 2026-10-04, and every example below was run on Node.js 26, 24 and 22 with identical results. Authentication
in Node.js is almost entirely a library and protocol question rather than a runtime one: the core gives you the building blocks (node:crypto
for random identifiers, hashing and constant-time comparison, node:http for headers and cookies), and the ecosystem (express-session,
Passport, jsonwebtoken, the @fastify/* plugins) assembles them into sessions and tokens. This page shows the two mainstream designs side by
side, server-side sessions held in a store and stateless signed tokens, then adds the protections that sit around either of them: CSRF defences,
rate limiting and lockout, and single-use email verification tokens. Password hashing itself is covered in
Crypto, Passwords & Secrets and is not repeated here; the examples reuse its scrypt recipe.
The running scenario is the Bookshelf application from
Introduction & Architecture; Express and Fastify themselves are introduced in
Web Frameworks: Express & Fastify, and the protocol theory (OAuth 2.0, OpenID Connect,
token formats) lives in the OAuth section, which this page links to rather than re-explaining.
Sessions or tokens: choosing a design
Authentication answers "who is this?" once, at login; the rest of the design is how the server recognises the same client on the next request. There are two answers. A session keeps the state on the server: the client holds an opaque, random identifier in a cookie and the server looks it up in a store (memory, Redis, a database) on every request. A stateless token keeps the state in the client: the server signs a small set of claims (a JSON Web Token) and verifies the signature on every request without any lookup.
| Concern | Server-side session (opaque id in a cookie) | Stateless JWT (signed claims) |
|---|---|---|
Revocation and logout |
Immediate: delete the record in the store. |
Hard. A token is valid until |
Request cost |
One store lookup per request (Redis: sub-millisecond on a local network). |
One signature verification; no I/O. |
Changing a user’s role or disabling an account |
Takes effect on the next request. |
Takes effect when the token expires or is refreshed. |
Horizontal scaling |
Needs a shared store, or sticky routing; see Cluster & Scaling. |
Any instance that has the verification key can authenticate the request. |
Browser story |
Cookie with |
Usually sent in an |
Best fit |
A server-rendered site or a first-party single-page app on the same site as its API. |
Service-to-service calls, short-lived API access tokens, and third-party clients (see Access & Refresh Tokens). |
For a first-party web application with a single backend, a server-side session is the simpler and safer default, because logout and revocation work. Reach for tokens when several services must validate callers independently, and keep them short-lived and paired with a rotating refresh token. A JWT is a format, not a security feature: every property in the table comes from how it is issued and verified, which is why this page spends more time on the verification options than on the signing call. The OAuth section covers the format in JWT & JOSE and the opaque alternative in Opaque Tokens, Introspection & Revocation.
The version map
| Package | Version used | Role and notes |
|---|---|---|
|
5.2.1 |
Express 5 forwards a rejected promise from an |
|
1.19.0 |
Server-side sessions with a pluggable store; the default |
|
10.0.0 |
Redis store for |
|
0.7.0, 1.0.0, 4.0.1 |
Strategy framework. Since 0.6.0 (2022-05-20) |
|
9.0.3 |
Sign and verify JWTs. Version 9 (2022-12-21) rejects unsigned tokens by default, requires RSA keys of at least 2048 bits and checks that the key type matches the algorithm. |
|
4.0.3 |
Double-submit-cookie CSRF protection for Express. |
|
8.7.0 |
Rate limiting middleware with an in-memory default store; exports |
|
5.12.5, 11.1.2, 10.2.2, 11.2.0 |
The Fastify 5 counterparts; |
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Unchanged across 22, 24 and 26; the Argon2 functions arrived in v24.7.0 (see Crypto, Passwords & Secrets). |
|
Stable |
No longer experimental in v24.10.0 and v22.21.0. The examples read every secret with |
|
Stable |
Used to drive the examples; |
Run the examples from a scratch directory (never inside another project), install the packages once, and generate the secrets with Node itself so that nothing is hard-coded in source or committed:
npm init -y && npm pkg set type=module
npm i express express-session connect-redis redis passport passport-local passport-jwt \
jsonwebtoken csrf-csrf cookie-parser express-rate-limit \
fastify @fastify/cookie @fastify/jwt @fastify/rate-limit
# Each value is generated once and kept out of version control (.env is in .gitignore).
{
echo "SESSION_SECRET=$(node -p "crypto.randomBytes(32).toString('hex')")"
echo "JWT_SECRET=$(node -p "crypto.randomBytes(32).toString('base64url')")"
echo "CSRF_SECRET=$(node -p "crypto.randomBytes(32).toString('hex')")"
echo "REDIS_URL=redis://127.0.0.1:6379"
} > .env
Every example then starts with the same guard, which is the correct form of the "secret with a default" habit: a missing secret stops the process instead of silently falling back to a string everyone can read in the repository.
A user table with scrypt hashes
The examples share one small module: an in-memory user table, scrypt hashing with a per-password salt, the asynchronous crypto.scrypt() so the
event loop is not blocked, a constant-time comparison, and a dummy hash so that a login attempt for an unknown address takes about as long as one for
a real address (otherwise response time reveals which accounts exist). The parameters and the reasons for them are in
Crypto, Passwords & Secrets; production code would store the table in
a database and prefer Argon2id on Node.js 24.7 and later.
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
export async function hashPassword(password) {
const salt = randomBytes(16);
const key = await scryptAsync(password.normalize('NFKC'), salt, 64);
return `scrypt$${salt.toString('base64url')}$${key.toString('base64url')}`;
}
export async function verifyPassword(password, stored) {
const [, salt, hash] = stored.split('$');
const expected = Buffer.from(hash, 'base64url');
const actual = await scryptAsync(password.normalize('NFKC'), Buffer.from(salt, 'base64url'), expected.length);
return timingSafeEqual(actual, expected);
}
// A tiny in-memory user table for the examples; Bookshelf uses SQLite or PostgreSQL.
export const users = new Map();
// Returns the new user, or null when the address is already registered: an existing account is never overwritten.
// The password is hashed first in both cases, so a duplicate signup costs the same time as a new one.
export async function addUser(email, password) {
const passwordHash = await hashPassword(password);
if (users.has(email)) return null;
const user = { id: randomBytes(8).toString('hex'), email, role: 'reader', passwordHash, verified: false };
users.set(email, user);
return user;
}
// Used to keep timing similar when the account does not exist.
export const dummyHash = await hashPassword('not-a-real-password');
Cookies: the attributes that matter
A session identifier travels in a cookie, so the cookie’s attributes are the first line of defence. They are set by the server in the Set-Cookie
response header; the browser enforces them, so a missing attribute is a hole no server-side check can close.
| Attribute | What it does | Recommended value |
|---|---|---|
|
Hides the cookie from |
Always for session and refresh cookies. |
|
Sends the cookie only over HTTPS (browsers also accept it on |
Always in production. |
|
Controls whether the cookie accompanies cross-site requests. |
|
|
|
Omit |
|
Lifetime of the cookie in the browser. Without either it is a session cookie that disappears when the browser closes (browsers may restore it). |
Short, and enforced again on the server; the browser is not a trustworthy clock. |
|
The browser refuses the cookie unless it is |
Use it for the session cookie where the deployment allows (HTTPS everywhere). |
SameSite is a defence in depth against CSRF, not a replacement for a CSRF token, because it works on the site (registrable domain) and not
the origin, and because Lax still allows cross-site top-level GET. The next sections apply these attributes; the CSRF section builds on them.
Source: MDN: Set-Cookie header
Server-side sessions with express-session and Redis
express-session stores only a session id in the cookie (signed with the secret so a forged id is discarded without a store lookup) and keeps the
data in a store. The example below uses Redis, with the settings that matter: saveUninitialized: false so anonymous visitors do not create store
entries, resave: false (the Redis store implements touch to extend the lifetime), a custom cookie name instead of the default connect.sid
(which reveals the framework), and the hardened cookie attributes from the table above. It also shows two things people forget: trust proxy,
and regenerate() at login.
import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
import { addUser, users, verifyPassword, dummyHash } from './users.js';
const { SESSION_SECRET, REDIS_URL } = process.env;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required'); // fail fast, no fallback string
const redis = createClient({ url: REDIS_URL });
await redis.connect();
const app = express();
app.set('trust proxy', 1); // one reverse proxy in front: required for secure cookies behind TLS termination
app.use(express.json());
app.use(session({
store: new RedisStore({ client: redis, prefix: 'bookshelf:sess:', ttl: 60 * 60 }),
name: 'bookshelf.sid',
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 1000, path: '/' },
}));
const regenerate = (req) => new Promise((ok, fail) => req.session.regenerate((e) => (e ? fail(e) : ok())));
const save = (req) => new Promise((ok, fail) => req.session.save((e) => (e ? fail(e) : ok())));
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.get(email);
const ok = await verifyPassword(password, user ? user.passwordHash : dummyHash);
if (!user || !ok) return res.status(401).json({ error: 'invalid credentials' });
await regenerate(req); // new session id at the privilege change: defeats session fixation
req.session.userId = user.id;
req.session.createdAt = Date.now();
await save(req);
res.json({ ok: true });
});
app.get('/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'not signed in' });
res.json({ userId: req.session.userId });
});
app.post('/logout', (req, res, next) => {
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie('bookshelf.sid', { path: '/' });
res.status(204).end();
});
});
await addUser('ada@example.com', 'correct horse battery staple');
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const https = { 'x-forwarded-proto': 'https', 'content-type': 'application/json' };
let res = await fetch(`${base}/me`, { headers: https });
console.log('anonymous /me:', res.status, '| Set-Cookie:', res.headers.get('set-cookie'));
res = await fetch(`${base}/login`, { method: 'POST', headers: https, body: JSON.stringify({ email: 'ada@example.com', password: 'wrong' }) });
console.log('bad login:', res.status);
res = await fetch(`${base}/login`, { method: 'POST', headers: https, body: JSON.stringify({ email: 'ada@example.com', password: 'correct horse battery staple' }) });
const raw = res.headers.get('set-cookie');
console.log('login:', res.status);
console.log('Set-Cookie:', raw.replace(/(bookshelf\.sid=)[^;]{8}[^;]*/, '$1<id…>'));
const cookie = raw.split(';')[0];
res = await fetch(`${base}/me`, { headers: { ...https, cookie } });
console.log('with cookie /me:', res.status, await res.json().then((j) => Object.keys(j)));
console.log('keys in Redis:', (await redis.keys('bookshelf:sess:*')).length);
res = await fetch(`${base}/logout`, { method: 'POST', headers: { ...https, cookie } });
console.log('logout:', res.status);
res = await fetch(`${base}/me`, { headers: { ...https, cookie } });
console.log('after logout /me:', res.status, '| keys in Redis:', (await redis.keys('bookshelf:sess:*')).length);
// Without X-Forwarded-Proto the Secure cookie is not issued over plain HTTP:
res = await fetch(`${base}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'ada@example.com', password: 'correct horse battery staple' }) });
console.log('login over plain HTTP, Set-Cookie:', res.headers.get('set-cookie'));
server.close();
await redis.quit();
anonymous /me: 401 | Set-Cookie: null
bad login: 401
login: 200
Set-Cookie: bookshelf.sid=<id…>; Path=/; Expires=Sun, 04 Oct 2026 00:31:45 GMT; HttpOnly; Secure; SameSite=Lax
with cookie /me: 200 [ 'userId' ]
keys in Redis: 2
logout: 204
after logout /me: 401 | keys in Redis: 1
login over plain HTTP, Set-Cookie: null
Four details in that output deserve a closer look.
-
The cookie is only issued when the connection looks secure. With
secure: true,express-sessionsets the cookie only if Express considers the request HTTPS. Behind a TLS-terminating proxy that is true only whentrust proxyis set and the proxy sendsX-Forwarded-Proto: https; the last line of the output shows that over plain HTTP no cookie is issued at all, and the user appears to be unable to log in. The fix is the proxy configuration, neversecure: false. Settrust proxyto the exact number of hops (1for one reverse proxy), not totrue, which trusts a client-suppliedX-Forwarded-Forand also defeats IP-based rate limiting (see Server Timeouts, Limits & Hardening for the proxy side). -
Rotation at privilege change.
req.session.regenerate()creates a new session id (and a new store record) after the credentials are checked. If an attacker had planted a known id in the victim’s browser before login (session fixation), that id is worthless afterwards. Do the same after any other privilege change, such as a step-up with a second factor or a role change. -
Two lifetimes. The cookie
maxAge(and the store’sttl) is an idle timeout thattouchextends on each request whenrollingis on; add an absolute limit yourself by storingcreatedAtin the session (the example does) and rejecting sessions older than, say, eight hours. OWASP recommends both. -
Logout is deletion.
req.session.destroy()removes the record from Redis (the output shows the key count dropping to zero) andclearCookie()removes the cookie. This is the capability tokens lack: the server can end a session at any time, for example "sign out everywhere" by deleting every record that carries a user id.
Two operational notes. Rotating the signing secret without logging everyone out works by passing an array: the first element signs new cookies,
the later ones are still accepted for verification. And the plain-HTTP request at the end of the example still created a (now unreachable) record
in Redis before the cookie was refused; with an idle ttl that record expires on its own, which is one more reason to set one.
Passport: strategies for authentication
Passport is authentication middleware with a deliberately narrow job: a strategy turns the incoming request into a user (or a failure), and
Passport then either establishes a login session or just attaches req.user. The strategy is the unit of reuse. There are hundreds of them, and
the ones this page needs are passport-local (username and password in the request body), passport-jwt (a bearer token) and the OAuth 2.0 and
OpenID Connect strategies for logging in with a third party, covered in Social Login & Federation
and OpenID Connect.
| Piece | Purpose |
|---|---|
|
Registers a strategy. A |
|
Middleware that adds Passport to the request. Required. |
|
Restores |
|
Convert a user to what is stored in the session (store only an id) and back to a user on every request. |
|
The route middleware that runs the strategy; use |
|
Establish, end and test a login. Since 0.6.0 login and logout regenerate the session, and |
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { addUser, users, verifyPassword, dummyHash } from './users.js';
const { SESSION_SECRET } = process.env;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required');
const byId = new Map();
passport.use(new LocalStrategy({ usernameField: 'email' }, async (email, password, done) => {
try {
const user = users.get(email);
const ok = await verifyPassword(password, user ? user.passwordHash : dummyHash);
// One message for "unknown user" and "wrong password": no account enumeration.
return ok && user ? done(null, user) : done(null, false, { message: 'invalid credentials' });
} catch (err) {
return done(err);
}
}));
// Keep the session small: store only the id, load the rest per request.
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => done(null, byId.get(id) ?? false));
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(session({
name: 'bookshelf.sid',
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 },
})); // default MemoryStore: fine for a demo, never for production
app.use(passport.initialize());
app.use(passport.session());
app.post('/login', passport.authenticate('local', { failWithError: true }), (req, res) => {
res.json({ ok: true, role: req.user.role });
}, (err, req, res, next) => res.status(err.status ?? 500).json({ error: 'invalid credentials' }));
const ensureAuth = (req, res, next) => (req.isAuthenticated() ? next() : res.status(401).json({ error: 'not signed in' }));
app.get('/me', ensureAuth, (req, res) => res.json({ role: req.user.role }));
app.post('/logout', ensureAuth, (req, res, next) => {
req.logout((err) => (err ? next(err) : res.status(204).end())); // logout() is asynchronous since Passport 0.6
});
const ada = await addUser('ada@example.com', 'correct horse battery staple');
byId.set(ada.id, ada);
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const h = { 'x-forwarded-proto': 'https', 'content-type': 'application/json' };
const sid = (setCookie) => decodeURIComponent(setCookie.split(';')[0].split('=')[1]).slice(2, 10) + '…';
// An attacker plants a session id before login (session fixation attempt).
let res = await fetch(`${base}/me`, { headers: h });
console.log('anonymous:', res.status);
res = await fetch(`${base}/login`, { method: 'POST', headers: h, body: JSON.stringify({ email: 'ada@example.com', password: 'nope' }) });
console.log('bad password:', res.status, await res.json());
res = await fetch(`${base}/login`, { method: 'POST', headers: h, body: JSON.stringify({ email: 'ada@example.com', password: 'correct horse battery staple' }) });
console.log('login:', res.status, await res.json());
const cookie = res.headers.get('set-cookie').split(';')[0];
console.log('session id after login starts with:', sid(res.headers.get('set-cookie')));
res = await fetch(`${base}/me`, { headers: { ...h, cookie } });
console.log('/me:', res.status, await res.json());
res = await fetch(`${base}/logout`, { method: 'POST', headers: { ...h, cookie } });
console.log('logout:', res.status);
res = await fetch(`${base}/me`, { headers: { ...h, cookie } });
console.log('/me after logout (old cookie):', res.status);
server.close();
anonymous: 401
bad password: 401 { error: 'invalid credentials' }
login: 200 { ok: true, role: 'reader' }
session id after login starts with: phQ9LRYm…
/me: 200 { role: 'reader' }
logout: 204
/me after logout (old cookie): 401
Source: Passport: Strategies and passport-local
Passport 0.6.0 made session rotation automatic, which can be confirmed rather than assumed. The script below asks for a session before login and again after it, and the identifier changes:
// Is the session id rotated at login by Passport 0.7? Compare ids before and after.
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
passport.use(new LocalStrategy((u, p, done) => done(null, { id: 'u1' })));
passport.serializeUser((u, done) => done(null, u.id));
passport.deserializeUser((id, done) => done(null, { id }));
const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true }));
app.use(passport.initialize(), passport.session());
app.get('/anon', (req, res) => res.json({ sid: req.sessionID.slice(0, 6) }));
app.post('/login', passport.authenticate('local'), (req, res) => res.json({ sid: req.sessionID.slice(0, 6) }));
const s = app.listen(0); await new Promise((r) => s.once('listening', r));
const b = `http://127.0.0.1:${s.address().port}`;
let r = await fetch(`${b}/anon`); const c = r.headers.get('set-cookie').split(';')[0];
console.log('before login:', (await r.json()).sid);
r = await fetch(`${b}/login`, { method: 'POST', headers: { cookie: c, 'content-type': 'application/json' }, body: JSON.stringify({ username: 'a', password: 'b' }) });
console.log('after login :', (await r.json()).sid);
s.close();
before login: GhpkxP
after login : H4vxra
Source: Passport: Sessions
On Passport 0.5 and earlier, req.login() reused the session id, so a hand-written regenerate() was needed; code and tutorials from that era
that call req.logout() without a callback also throw on 0.6 and later. Session-fixation protection is therefore one of the things that changed
under existing applications when Passport was upgraded.
Stateless tokens with jsonwebtoken
A JWT is three base64url-encoded parts (header, payload, signature) joined with dots. Anyone can read the payload; the signature only proves it
was produced by a holder of the key. Two rules follow, and breaking either is the source of most JWT vulnerabilities: never put anything in a token
that the client must not see, and never trust a token until verify() has checked the signature and the claims with the options you chose.
The example issues a short-lived token with the registered claims (iss, aud, sub, exp, jti) and then tries five attacks on the verifier.
jwt.decode() is shown last to make a point: it parses a token without checking anything, so using it for authorization is the same as having
no authentication.
import jwt from 'jsonwebtoken';
import { generateKeyPairSync } from 'node:crypto';
const { JWT_SECRET } = process.env;
if (!JWT_SECRET) throw new Error('JWT_SECRET is required'); // no `|| 'dev-secret'` fallback
const short = (t) => `<JWT: ${t.split('.').length} parts, ${t.length} characters>`;
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
// Issue: short expiry, issuer, audience, subject, unique id.
const token = jwt.sign({ role: 'reader' }, JWT_SECRET, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'bookshelf',
audience: 'bookshelf-api',
subject: 'user-123',
jwtid: crypto.randomUUID(),
});
console.log('token:', short(token));
// Verify: pin algorithms, issuer and audience.
const verifyOptions = { algorithms: ['HS256'], issuer: 'bookshelf', audience: 'bookshelf-api' };
const claims = jwt.verify(token, JWT_SECRET, verifyOptions);
console.log('claims:', { sub: claims.sub, role: claims.role, ttl: claims.exp - claims.iat });
const attempt = (label, fn) => {
try { fn(); console.log(label, '-> ACCEPTED'); } catch (e) { console.log(label, '->', e.name + ':', e.message); }
};
// 1. A forged token with "alg": "none".
const forged = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 'user-123', role: 'admin', iss: 'bookshelf', aud: 'bookshelf-api' })}.`;
attempt('alg none', () => jwt.verify(forged, JWT_SECRET, verifyOptions));
// 2. Expired token.
const old = jwt.sign({ role: 'reader' }, JWT_SECRET, { algorithm: 'HS256', expiresIn: -10, issuer: 'bookshelf', audience: 'bookshelf-api' });
attempt('expired', () => jwt.verify(old, JWT_SECRET, verifyOptions));
// 3. Wrong audience.
attempt('wrong audience', () => jwt.verify(token, JWT_SECRET, { ...verifyOptions, audience: 'other-api' }));
// 4. Tampered payload.
const [h, , s] = token.split('.');
attempt('tampered', () => jwt.verify(`${h}.${b64({ sub: 'user-123', role: 'admin' })}.${s}`, JWT_SECRET, verifyOptions));
// 5. Algorithm confusion: an HS256 token "signed" with an RSA public key must not verify against an RS256-only config.
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const pub = publicKey.export({ type: 'spki', format: 'pem' });
const confused = jwt.sign({ sub: 'user-123', role: 'admin' }, pub, { algorithm: 'HS256' });
attempt('HS256 with RSA public key', () => jwt.verify(confused, pub, { algorithms: ['RS256'] }));
// Asymmetric: the API only holds the public key.
const rs = jwt.sign({ role: 'reader' }, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
console.log('RS256 verify:', jwt.verify(rs, pub, { algorithms: ['RS256'] }).role);
// decode() does NOT verify anything.
console.log('decode() of the forged token:', jwt.decode(forged).role);
token: <JWT: 3 parts, 285 characters>
claims: { sub: 'user-123', role: 'reader', ttl: 900 }
alg none -> JsonWebTokenError: jwt signature is required
expired -> TokenExpiredError: jwt expired
wrong audience -> JsonWebTokenError: jwt audience invalid. expected: other-api
tampered -> JsonWebTokenError: invalid signature
HS256 with RSA public key -> JsonWebTokenError: invalid algorithm
RS256 verify: reader
decode() of the forged token: admin
| Attack | Defence and why it works |
|---|---|
|
Version 9 rejects unsigned tokens by default, and pinning |
Expired token |
Set |
Wrong audience or issuer |
Pin |
Tampered payload |
The signature covers the payload; changing a claim breaks it. This is also why the key must stay secret and long (32 random bytes for HS256). |
Algorithm confusion |
A verifier that accepts both HMAC and RSA algorithms may be fooled into treating a public key as an HMAC secret. Pin |
|
|
Further rules that the code cannot enforce for you:
-
Symmetric versus asymmetric. HS256 (one shared secret) is fine when the issuer and the verifier are the same service. When other services verify tokens, use an asymmetric algorithm (RS256, PS256, ES256) so that they hold only the public key, as in the last lines of the example. Observed with
jsonwebtoken9.0.3: signing withEdDSAis rejected as an invalid algorithm; thejoselibrary andfast-jwt(used by@fastify/jwt) support it. -
Keep payloads small and non-sensitive, and treat claims like
roleas a cache that is stale until the token expires. -
Do not store tokens in
localStorage. Any script on the page can read them. Prefer anHttpOnlycookie, or keep the access token in memory and the refresh token in anHttpOnlycookie; see Browser-Based Apps for the standards position. -
Revocation is a design decision. With a five-minute access token, "revoke" means "wait five minutes", plus revoking the refresh token. If that is not acceptable, you want sessions (or opaque tokens with introspection).
Refresh tokens, rotation and passport-jwt
A five-minute access token needs a way to be renewed without asking for the password again. The standard answer is a refresh token: a long-lived, opaque credential that can only be exchanged at one endpoint. It is deliberately not a JWT here: a random 32-byte value carries no claims to leak, and because it is looked up on the server, it can be revoked. Three practices make it safe:
-
store only a digest (SHA-256 is enough for a value with 256 bits of entropy; the slow password hashes are for low-entropy passwords);
-
rotate it on every use, so a stolen token works at most once;
-
remember which tokens have been used, and when one is presented a second time, revoke the whole family, since either the thief or the legitimate client now holds a dead token and the server cannot tell which.
The example puts the access token in the Authorization header (verified by passport-jwt with pinned algorithms, issuer and audience) and the
refresh token in an HttpOnly, Secure, SameSite=Strict cookie whose Path is limited to the refresh endpoint, so the browser sends it nowhere else.
import express from 'express';
import passport from 'passport';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import jwt from 'jsonwebtoken';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { addUser, users, verifyPassword, dummyHash } from './users.js';
const { JWT_SECRET } = process.env;
if (!JWT_SECRET) throw new Error('JWT_SECRET is required');
const ISSUER = 'bookshelf';
const AUDIENCE = 'bookshelf-api';
const ACCESS_TTL = '5m';
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const byId = new Map();
// Refresh tokens are opaque random strings; only their SHA-256 digest is stored.
const refreshTokens = new Map(); // digest -> { userId, familyId, used, expiresAt }
const digest = (t) => createHash('sha256').update(t).digest('hex');
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET,
algorithms: ['HS256'], // pinned: passport-jwt hands this to jsonwebtoken
issuer: ISSUER,
audience: AUDIENCE,
}, (payload, done) => done(null, byId.get(payload.sub) ?? false)));
const accessToken = (user) => jwt.sign({ role: user.role }, JWT_SECRET, {
algorithm: 'HS256', expiresIn: ACCESS_TTL, issuer: ISSUER, audience: AUDIENCE, subject: user.id, jwtid: randomUUID(),
});
function issueRefresh(res, userId, familyId = randomUUID()) {
const token = randomBytes(32).toString('base64url');
refreshTokens.set(digest(token), { userId, familyId, used: false, expiresAt: Date.now() + REFRESH_TTL_MS });
res.cookie('refresh', token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: REFRESH_TTL_MS });
}
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(passport.initialize());
app.post('/auth/login', async (req, res) => {
const user = users.get(req.body.email);
const ok = await verifyPassword(req.body.password ?? '', user ? user.passwordHash : dummyHash);
if (!user || !ok) return res.status(401).json({ error: 'invalid credentials' });
issueRefresh(res, user.id);
res.json({ accessToken: accessToken(user), expiresIn: 300 });
});
app.post('/auth/refresh', (req, res) => {
const presented = /(?:^|; )refresh=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
const record = presented && refreshTokens.get(digest(presented));
if (!record || record.expiresAt < Date.now()) return res.status(401).json({ error: 'invalid refresh token' });
if (record.used) { // reuse of a rotated token: assume theft, revoke the whole family
for (const [k, v] of refreshTokens) if (v.familyId === record.familyId) refreshTokens.delete(k);
return res.status(401).json({ error: 'refresh token reuse detected' });
}
record.used = true;
issueRefresh(res, record.userId, record.familyId); // rotation: every use returns a new token
res.json({ accessToken: accessToken(byId.get(record.userId)), expiresIn: 300 });
});
app.get('/books', passport.authenticate('jwt', { session: false }), (req, res) => res.json({ user: req.user.id, books: [] }));
const ada = await addUser('ada@example.com', 'correct horse battery staple');
byId.set(ada.id, ada);
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const h = { 'x-forwarded-proto': 'https', 'content-type': 'application/json' };
const cookieOf = (r) => r.headers.get('set-cookie').split(';')[0];
let res = await fetch(`${base}/books`);
console.log('no token:', res.status);
res = await fetch(`${base}/auth/login`, { method: 'POST', headers: h, body: JSON.stringify({ email: 'ada@example.com', password: 'correct horse battery staple' }) });
console.log('refresh cookie attributes:', res.headers.get('set-cookie').replace(/refresh=[^;]+/, 'refresh=<token…>').replace(/Expires=[^;]+; /, ''));
const { accessToken: at } = await res.json();
const rt1 = cookieOf(res);
res = await fetch(`${base}/books`, { headers: { authorization: `Bearer ${at}` } });
console.log('with access token:', res.status);
res = await fetch(`${base}/auth/refresh`, { method: 'POST', headers: { ...h, cookie: rt1 } });
console.log('refresh #1:', res.status);
const rt2 = cookieOf(res);
res = await fetch(`${base}/auth/refresh`, { method: 'POST', headers: { ...h, cookie: rt1 } });
console.log('replaying the old refresh token:', res.status, await res.json());
res = await fetch(`${base}/auth/refresh`, { method: 'POST', headers: { ...h, cookie: rt2 } });
console.log('the newer token after reuse detection:', res.status);
server.close();
no token: 401
refresh cookie attributes: refresh=<token…>; Max-Age=604800; Path=/auth/refresh; HttpOnly; Secure; SameSite=Strict
with access token: 200
refresh #1: 200
replaying the old refresh token: 401 { error: 'refresh token reuse detected' }
the newer token after reuse detection: 401
passport-jwt passes algorithms, issuer and audience straight to jsonwebtoken.verify(); leaving algorithms out accepts whatever the
library’s default allows for the key type, so state it explicitly. In production the refreshTokens map is a database table with an index on the
digest and a job that deletes expired rows, and the secret is the output of a secrets manager rather than an environment file. The reuse-detection
branch is the interesting one: the replayed old token yields 401 and the legitimate newer token stops working too, forcing a fresh login. That
trade is intentional. OAuth-based flows describe the same pattern; see Access & Refresh Tokens and
Logout & Session Management.
CSRF protection
Cross-site request forgery exploits the fact that browsers attach cookies automatically: a malicious page makes the victim’s browser send
POST /books to your site, and the session cookie goes along. Only requests that change state matter, and only when the credential is a cookie.
Consequences for the designs above:
-
A session cookie or a refresh cookie authenticating a state-changing request needs CSRF protection.
-
A bearer token in the
Authorizationheader is not sent automatically, so a request authenticated only that way is not forgeable cross-site. (Cross-origin requests can still need a CORS policy; that is a different control.) -
GETmust never change state, so that the safe-method exemption in every library is actually safe.
Defend in layers, cheapest first:
| Layer | How it works | Limits |
|---|---|---|
|
The browser does not send the cookie on cross-site |
Same-site, not same-origin: a vulnerable sibling subdomain is not blocked. Older browsers ignore it. |
Fetch Metadata ( |
Browsers label every request as |
The header is absent in non-browser clients and old browsers, so treat "absent" as "not a browser request" and keep the token layer. |
Synchronizer token |
A random token is stored in the session and must be echoed in a custom header (or a hidden form field) of every state-changing request. An attacker’s page cannot read it. |
Needs a session. |
Double-submit cookie (signed) |
Stateless: the server sets a token in a cookie and requires the same token in a header; the signature binds it to the session or user. |
Weaker if the token is not bound to the session, or if a sibling subdomain can set cookies; use the |
The first example implements the synchronizer token by hand, because it is only a few lines, together with the Fetch Metadata check. Note the
constant-time comparison, the header (never a query string, which ends up in logs and Referer), and the __Host- cookie name.
import express from 'express';
import session from 'express-session';
import { randomBytes, timingSafeEqual } from 'node:crypto';
const { SESSION_SECRET } = process.env;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required');
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Layer 1: Fetch Metadata. Browsers send Sec-Fetch-Site; refuse state changes that start on another site.
function fetchMetadata(req, res, next) {
const site = req.get('sec-fetch-site');
if (SAFE_METHODS.has(req.method) || site === undefined || site === 'same-origin' || site === 'none') return next();
return res.status(403).json({ error: 'cross-site request refused' });
}
// Layer 2: synchronizer token bound to the session, sent in a header (never in the URL).
function csrfToken(req) {
req.session.csrf ??= randomBytes(32).toString('base64url');
return req.session.csrf;
}
function verifyCsrf(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const sent = Buffer.from(req.get('x-csrf-token') ?? '');
const expected = Buffer.from(req.session.csrf ?? '');
if (sent.length === 0 || sent.length !== expected.length || !timingSafeEqual(sent, expected)) {
return res.status(403).json({ error: 'invalid csrf token' });
}
return next();
}
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(session({
name: '__Host-bookshelf.sid', // __Host- prefix: Secure, Path=/, no Domain attribute
secret: SESSION_SECRET, resave: false, saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' },
}));
app.use(fetchMetadata);
app.get('/csrf-token', (req, res) => res.json({ token: csrfToken(req) }));
app.post('/books', verifyCsrf, (req, res) => res.status(201).json({ created: req.body.title }));
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const h = { 'x-forwarded-proto': 'https', 'content-type': 'application/json' };
const body = JSON.stringify({ title: 'Dune' });
let res = await fetch(`${base}/csrf-token`, { headers: h });
const cookie = res.headers.get('set-cookie').split(';')[0];
const { token } = await res.json();
console.log('cookie name:', cookie.split('=')[0]);
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie }, body });
console.log('no token:', res.status);
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie, 'x-csrf-token': 'wrong' }, body });
console.log('wrong token:', res.status);
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie, 'x-csrf-token': token }, body });
console.log('right token:', res.status);
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie, 'x-csrf-token': token, 'sec-fetch-site': 'cross-site' }, body });
console.log('right token, cross-site (Fetch Metadata):', res.status);
server.close();
cookie name: __Host-bookshelf.sid
no token: 403
wrong token: 403
right token: 201
right token, cross-site (Fetch Metadata): 403
Source: OWASP CSRF Prevention Cheat Sheet and web.dev: Protect your resources from web attacks with Fetch Metadata
When the application is a single-page app that does not hold a server-side session, the double-submit pattern is the usual choice, and
csrf-csrf implements it with an HMAC-signed token. The old csurf package was deprecated and its repository is archived, and several tutorials still
recommend it. Two of the library’s own recommendations are visible in the code below: bind the token to the session identifier with
getSessionIdentifier, and read the token from one explicit place (a header), never from a fall-through of body, query and header.
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
const { SESSION_SECRET, CSRF_SECRET } = process.env;
if (!SESSION_SECRET || !CSRF_SECRET) throw new Error('SESSION_SECRET and CSRF_SECRET are required');
const { generateCsrfToken, doubleCsrfProtection, invalidCsrfTokenError } = doubleCsrf({
getSecret: () => CSRF_SECRET,
getSessionIdentifier: (req) => req.session.id, // ties the token to the session
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { sameSite: 'lax', secure: true, httpOnly: true, path: '/' },
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: true, cookie: { secure: true, httpOnly: true, sameSite: 'lax' } }));
app.use(cookieParser(SESSION_SECRET)); // after express-session, before the CSRF middleware
app.get('/csrf-token', (req, res) => res.json({ token: generateCsrfToken(req, res) }));
app.use(doubleCsrfProtection);
app.post('/books', (req, res) => res.status(201).json({ ok: true }));
app.use((err, req, res, next) => (err === invalidCsrfTokenError ? res.status(403).json({ error: 'invalid csrf token' }) : next(err)));
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const h = { 'x-forwarded-proto': 'https', 'content-type': 'application/json' };
let res = await fetch(`${base}/csrf-token`, { headers: h });
const cookies = res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
const { token } = await res.json();
console.log('cookies set:', res.headers.getSetCookie().map((c) => c.split('=')[0]));
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie: cookies }, body: '{}' });
console.log('cookie only:', res.status);
res = await fetch(`${base}/books`, { method: 'POST', headers: { ...h, cookie: cookies, 'x-csrf-token': token }, body: '{}' });
console.log('cookie + header:', res.status);
server.close();
cookies set: [ '__Host-psifi.x-csrf-token', 'connect.sid' ]
cookie only: 403
cookie + header: 201
Source: csrf-csrf documentation and csurf (archived)
In Fastify the equivalent is the @fastify/csrf-protection plugin, which builds on @fastify/cookie (or @fastify/session) and exposes
reply.generateCsrf() and a fastify.csrfProtection hook. The principles above are identical.
Source: @fastify/csrf-protection
Rate limiting and lockout
Passwords are the weakest credential, and the attacks on them are volume attacks: brute force against one account, and credential stuffing (leaked password lists replayed across many accounts). Two complementary controls address them. A rate limiter bounds how many requests one client (usually one IP address) may make in a window and protects every route, including expensive ones such as password hashing. An account lockout bounds failed attempts per account regardless of source address, which is what stops a distributed attack against one victim.
| Control | Protects against | Trade-off |
|---|---|---|
Per-IP rate limit |
One client hammering any endpoint; cheap to apply everywhere. |
Many clients behind one NAT share a quota; attackers rotate addresses. Needs a correct |
Per-account lockout with back-off |
Distributed guessing against one account. |
A denial-of-service lever: anyone can lock a known address. Use a short, growing delay rather than permanent lock, and notify the owner. |
Generic failure message and equal timing |
Account enumeration. |
None worth avoiding; see the dummy hash in the shared module. |
The example combines both. The limiter uses the standard RateLimit headers (draft 8) rather than the legacy X-RateLimit-* set, derives the key with
ipKeyGenerator() so that an IPv6 client cannot dodge the limit by rotating addresses inside its own /64 block, and the lockout counts failures for
unknown addresses too so the lockout behaviour does not reveal which accounts exist.
import express from 'express';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import { addUser, users, verifyPassword, dummyHash } from './users.js';
const app = express();
app.set('trust proxy', 1); // required to see the real client address behind one proxy; never `true`
app.use(express.json());
// Layer 1: per-IP limiter on the login route (in-memory store here; use a shared store with several instances).
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req) => ipKeyGenerator(req.ip),
message: { error: 'too many attempts, try again later' },
});
// Layer 2: per-account lockout with exponential back-off, independent of the source address.
const failures = new Map(); // email -> { count, lockedUntil }
const MAX_FAILURES = 5;
const lockSeconds = (count) => Math.min(2 ** (count - MAX_FAILURES) * 30, 15 * 60);
app.post('/login', loginLimiter, async (req, res) => {
const email = String(req.body.email ?? '').toLowerCase();
const state = failures.get(email) ?? { count: 0, lockedUntil: 0 };
if (state.lockedUntil > Date.now()) {
res.set('Retry-After', String(Math.ceil((state.lockedUntil - Date.now()) / 1000)));
return res.status(429).json({ error: 'account temporarily locked' });
}
const user = users.get(email);
const ok = await verifyPassword(String(req.body.password ?? ''), user ? user.passwordHash : dummyHash);
if (!user || !ok) {
state.count += 1;
if (state.count >= MAX_FAILURES) state.lockedUntil = Date.now() + lockSeconds(state.count) * 1000;
failures.set(email, state); // also tracked for unknown emails, so lockouts do not reveal which accounts exist
return res.status(401).json({ error: 'invalid credentials' });
}
failures.delete(email);
res.json({ ok: true });
});
await addUser('ada@example.com', 'correct horse battery staple');
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (password, email = 'ada@example.com') => fetch(`${base}/login`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }),
});
for (let i = 1; i <= 6; i += 1) {
const r = await post('wrong');
console.log(`attempt ${i}:`, r.status, r.headers.get('retry-after') ? `Retry-After: ${r.headers.get('retry-after')}` : '', r.headers.get('ratelimit') ? `RateLimit: ${r.headers.get('ratelimit')}` : '');
}
const r = await post('correct horse battery staple');
console.log('correct password while locked:', r.status);
// The per-IP limiter on its own: a different victim account each time.
for (let i = 1; i <= 25; i += 1) {
const r2 = await post('x', `user${i}@example.com`);
if (r2.status === 429) { console.log(`per-IP limit reached on request ${i + 7} from this address:`, r2.status); break; }
}
server.close();
attempt 1: 401 RateLimit: "20-in-15min"; r=19; t=900
attempt 2: 401 RateLimit: "20-in-15min"; r=18; t=900
attempt 3: 401 RateLimit: "20-in-15min"; r=17; t=900
attempt 4: 401 RateLimit: "20-in-15min"; r=16; t=900
attempt 5: 401 RateLimit: "20-in-15min"; r=15; t=900
attempt 6: 429 Retry-After: 30 RateLimit: "20-in-15min"; r=14; t=900
correct password while locked: 429
per-IP limit reached on request 21 from this address: 429
A few production points the example leaves out. The default store is per process: behind a load balancer or with several workers each instance counts separately, so use a shared store (the project maintains Redis-backed stores) and keep the lockout counters in Redis or the database as well. Applying the limiter before the body parser protects against large bodies, and the HTTP server’s own limits are in Server Timeouts, Limits & Hardening. Because password hashing is deliberately expensive, an unthrottled login route is also a CPU denial-of-service vector, which is why the limiter comes first in the middleware chain on that route. Multi-factor authentication and passwordless options reduce the value of stolen passwords and are described in Authentication Methods, 2FA & Passwordless.
Source: OWASP Authentication Cheat Sheet
Email verification tokens
Before an account is trusted, the owner of the address should prove control of it. The proof is a secret that only the mailbox receives. The same mechanism serves password reset and email-change confirmation. The rules:
-
Random, not derived. Use
crypto.randomBytes(32), never the address, a user id, a counter, a timestamp or a hash of them. A link of the form/verify?email=someone@example.comlets anyone "verify" any address by typing it. -
Store only a digest. A database leak then does not hand out working links. SHA-256 is appropriate for 256-bit random tokens.
-
Expire and consume. A fixed lifetime (the example uses 24 hours; use minutes for password reset) and single use; delete or mark the row on success.
-
Bind it to a purpose and a user, so a verification token cannot be replayed as a reset token.
-
Do not change state on
GET. Mail scanners and link previews fetch URLs automatically and would confirm accounts, or consume reset tokens, before the person clicks. The link opens a page; the button on it sends aPOST. -
Uniform responses. Signup answers "check your inbox" whether or not the address already exists, otherwise registration leaks membership.
-
Keep the token out of logs and referrers. Put it in the query string only on that one confirmation page, serve it with
Referrer-Policy: no-referrer, and exchange it immediately.
Sending the message itself (SMTP transports, HTML and text bodies, SPF, DKIM and DMARC, consent) is covered in
Email, Feeds & Web Scraping; the example uses an array in place of sendMail().
import express from 'express';
import { createHash, randomBytes } from 'node:crypto';
import { addUser, users } from './users.js';
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const pending = new Map(); // sha256(token) as hex -> { email, expiresAt }; a table with a unique index in a real database
// Issue: 256 random bits, only the digest is stored, the link never contains the e-mail address.
export function createVerificationToken(email) {
const token = randomBytes(32).toString('base64url');
pending.set(sha256(token), { email, expiresAt: Date.now() + TOKEN_TTL_MS });
return token;
}
const sent = []; // stand-in for Nodemailer's sendMail()
const sendVerificationMail = (email, token) => sent.push({ to: email, link: `https://bookshelf.example/verify?token=${token}` });
// The owner of an existing account learns that someone tried to register it; the attacker learns nothing.
const sendAlreadyRegisteredMail = (email) => sent.push({ to: email, text: 'You already have an account. Use "forgot password" if needed.' });
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
const { email, password } = req.body;
const user = await addUser(email, password); // hashes in both cases; null for an address that already has an account
if (user) sendVerificationMail(user.email, createVerificationToken(user.email));
else sendAlreadyRegisteredMail(email); // no new user, no token, the existing password hash is untouched
res.status(202).json({ message: 'check your inbox' }); // same status, body and (roughly) time either way
});
// GET only shows a confirmation page: mail scanners and link previews fetch URLs, they must not change state.
app.get('/verify', (req, res) => res.type('html').send('<form method="post"><button>Confirm my e-mail address</button></form>'));
app.post('/verify', (req, res) => {
const token = String(req.query.token ?? '');
const key = sha256(token); // lookup by digest of a high-entropy token: a fast hash is fine here
const record = pending.get(key);
if (!record || record.expiresAt < Date.now()) return res.status(400).json({ error: 'invalid or expired link' });
pending.delete(key); // single use
users.get(record.email).verified = true;
res.json({ verified: true });
});
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
let r = await fetch(`${base}/signup`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'grace@example.com', password: 'a long passphrase here' }) });
console.log('signup:', r.status);
const link = new URL(sent[0].link);
const token = link.searchParams.get('token');
console.log('link:', `${link.origin}${link.pathname}?token=${token.slice(0, 6)}…`, '| token chars:', token.length, '| contains e-mail:', sent[0].link.includes('grace'));
console.log('stored digests:', [...pending.keys()].map((k) => `${k.slice(0, 6)}…`));
const post = (t) => fetch(`${base}/verify?token=${encodeURIComponent(t)}`, { method: 'POST' });
console.log('GET /verify changes state:', (await fetch(`${base}/verify?token=${token}`)).status, users.get('grace@example.com').verified);
console.log('POST with a guessed value:', (await post('grace@example.com')).status);
console.log('POST with the real token:', (await post(token)).status, users.get('grace@example.com').verified);
console.log('POST again (single use):', (await post(token)).status);
const before = users.get('grace@example.com').passwordHash;
r = await fetch(`${base}/signup`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'grace@example.com', password: 'attacker chosen password' }) });
console.log('signup again for the same address:', r.status, '| account overwritten:', users.get('grace@example.com').passwordHash !== before, '| new token issued:', pending.size > 0);
console.log('second mail:', sent[1]);
server.close();
signup: 202
link: https://bookshelf.example/verify?token=uo-sJu… | token chars: 43 | contains e-mail: false
stored digests: [ '736edd…' ]
GET /verify changes state: 200 false
POST with a guessed value: 400
POST with the real token: 200 true
POST again (single use): 400
signup again for the same address: 202 | account overwritten: false | new token issued: false
second mail: {
to: 'grace@example.com',
text: 'You already have an account. Use "forgot password" if needed.'
}
The same pieces in Fastify
Fastify splits the work into plugins that you register in order: @fastify/cookie (parsing, signed cookies), @fastify/jwt (signing and
request.jwtVerify()), @fastify/rate-limit and, for sessions, @fastify/session. Authentication is usually a preHandler hook or a decorator, as
in Wexler-style preHandler examples but with the verification options set. The default for @fastify/jwt verification accepts every algorithm
the underlying fast-jwt supports, so pinning verify.algorithms is just as necessary as with jsonwebtoken. The example below uses
app.inject() rather than a listening socket.
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyJwt from '@fastify/jwt';
import fastifyRateLimit from '@fastify/rate-limit';
import { addUser, users, verifyPassword, dummyHash } from './users.js';
const { JWT_SECRET, SESSION_SECRET } = process.env;
if (!JWT_SECRET || !SESSION_SECRET) throw new Error('JWT_SECRET and SESSION_SECRET are required');
const app = Fastify({ trustProxy: 1, logger: false });
await app.register(fastifyCookie, { secret: SESSION_SECRET }); // enables signed cookies
await app.register(fastifyJwt, {
secret: JWT_SECRET,
sign: { algorithm: 'HS256', expiresIn: '10m', iss: 'bookshelf' },
verify: { algorithms: ['HS256'], allowedIss: 'bookshelf' }, // the default accepts every algorithm: pin it
});
await app.register(fastifyRateLimit, { global: false }); // opt in per route
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'unauthorized' });
}
});
app.post('/login', {
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
schema: { body: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string' }, password: { type: 'string' } } } },
}, async (request, reply) => {
const user = users.get(request.body.email);
const ok = await verifyPassword(request.body.password, user ? user.passwordHash : dummyHash);
if (!user || !ok) return reply.code(401).send({ error: 'invalid credentials' });
reply.setCookie('bookshelf.seen', '1', { signed: true, httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
return { accessToken: await reply.jwtSign({ role: user.role }, { sub: user.id }) };
});
app.get('/books', { preHandler: app.authenticate }, async (request) => ({ user: request.user.sub, role: request.user.role }));
await addUser('ada@example.com', 'correct horse battery staple');
const login = (password) => app.inject({ method: 'POST', url: '/login', payload: { email: 'ada@example.com', password } });
let res = await login('correct horse battery staple');
console.log('login:', res.statusCode, '| cookie:', res.cookies[0].name, res.cookies[0].httpOnly, res.cookies[0].secure, res.cookies[0].sameSite);
const { accessToken } = res.json();
res = await app.inject({ method: 'GET', url: '/books', headers: { authorization: `Bearer ${accessToken}` } });
console.log('with token:', res.statusCode, res.json().role);
res = await app.inject({ method: 'GET', url: '/books' });
console.log('without token:', res.statusCode);
for (let i = 1; i <= 6; i += 1) {
res = await login('wrong');
if (res.statusCode === 429) { console.log(`login failure ${i}: ${res.statusCode}`, res.headers['retry-after'] ? `retry-after ${res.headers['retry-after']}` : ''); break; }
}
await app.close();
login: 200 | cookie: bookshelf.seen true true Lax
with token: 200 reader
without token: 401
login failure 5: 429 retry-after 60
Source: @fastify/jwt, @fastify/cookie and @fastify/rate-limit
The limit counts every request to the route, successful or not, so the fifth wrong password is the sixth request in the window and is the first
to be refused. Use skipSuccessfulRequests in express-rate-limit (or a custom max function in Fastify) if only failures should count. For
Fastify’s own hook model, see the Fastify documentation on lifecycle hooks (onRequest and preHandler).
Source: Fastify: Hooks
What changed recently
| Change | Version and date | Effect |
|---|---|---|
Passport regenerates the session on |
passport 0.6.0, 2022-05-20 |
Fixation protection became automatic. Code calling |
|
jsonwebtoken 9.0.0, 2022-12-21 |
Old |
|
Repository notice |
Use |
|
Present in 8.7.0 as installed; the exact release that introduced it was not checked |
Custom |
Express 5 forwards rejected promises from handlers to the error handler |
express 5.0 |
|
|
Node.js 24.10.0 and 22.21.0; 24.7.0 |
Load secrets without |
No Node.js release between 22.23.3 and 26.10.0 changed the behaviour of the code on this page: the same scripts produced the same results on all three lines, and the differences are in the libraries listed above.
Where the books are outdated
The authentication chapters of Node.js Projects (Jonathan Wexler, O’Reilly, 2025) are a useful tour of the moving parts (sessions, Passport’s local strategy, JWTs, hooks as authentication middleware), but several habits they show should not be copied into real code, and the corrected form is on this page:
| Habit shown | Replace with |
|---|---|
Secrets written in source files, or a JWT secret with a fixed fallback string |
Read secrets from the environment ( |
Tokens issued without |
Short expiry, pinned |
Session cookies without |
The attribute table above; add |
No CSRF protection and no rate limiting on login |
A CSRF layer for every cookie-authenticated state change, and rate limit plus lockout on authentication routes. |
Email verification by a |
A random, hashed, expiring, single-use token confirmed with |
Low-cost password hashing (PBKDF2 with a low iteration count) and |
Argon2id or scrypt, asynchronous APIs and |
|
Pass a callback to |
Pitfalls and good practice
| Pitfall | Better practice |
|---|---|
|
Use Redis or a database store; the default store leaks memory and does not span processes. |
|
Set the exact hop count; otherwise |
|
The cookie is silently never sent; configure the proxy headers. |
Using |
Always |
Putting tokens in |
|
Long-lived JWTs as session replacements |
Minutes for access tokens; refresh tokens that rotate and are revocable; or use real sessions. |
Verification or reset links that change state on |
Link to a page; confirm with |
Different error messages or timings for unknown users |
One message, a dummy hash comparison. |
CSRF token in the URL |
Custom header, constant-time comparison, bound to the session. |
Per-process counters behind a load balancer |
Shared store for limits, lockouts and sessions. |
Rolling your own cryptography for tokens |
Use the libraries here, or Web Crypto and |
For a hardened HTTP layer around all of this, see Security Best Practices (Node.js’s threat model and supply-chain hygiene) and the OAuth security best practices; for the NestJS equivalents of these patterns, see NestJS Authentication and NestJS Cookies & Sessions.
References
-
Passport documentation: strategies, sessions, passport-local, passport-jwt
-
Express: Production best practices: Security and Express behind proxies
-
@fastify/jwt; @fastify/cookie; @fastify/rate-limit; @fastify/csrf-protection; Fastify hooks
-
RFC 6265: Cookies; RFC 7519: JSON Web Token; RFC 8725: JWT Best Current Practices
-
OWASP Cheat Sheet Series: Session Management, CSRF Prevention, Authentication, Forgot Password, Credential Stuffing Prevention, Password Storage, Session fixation
-
The OAuth section: OAuth, JWT & JOSE, Access & Refresh Tokens, Logout & Session Management, Browser-Based Apps
-
Node.js Projects, Jonathan Wexler (O’Reilly, 2025): publisher page