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.

Table 1. Server-side sessions compared with stateless JWTs
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 exp; revocation needs a deny-list or very short lifetimes, which brings state back.

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 HttpOnly, Secure, SameSite; needs CSRF protection.

Usually sent in an Authorization header (immune to CSRF, but the token must then live in JavaScript-readable memory) or in a cookie (back to CSRF).

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

Table 2. Packages used on this page (npm registry, resolved 2026-10-03)
Package Version used Role and notes

express

5.2.1

Express 5 forwards a rejected promise from an async handler to the error handler, so the examples do not wrap handlers in try/catch.

express-session

1.19.0

Server-side sessions with a pluggable store; the default MemoryStore leaks memory and is for development only.

connect-redis

10.0.0

Redis store for express-session; used with redis 6.3.0 (import { RedisStore } from 'connect-redis').

passport, passport-local, passport-jwt

0.7.0, 1.0.0, 4.0.1

Strategy framework. Since 0.6.0 (2022-05-20) req.login() and req.logout() regenerate the session and req.logout() takes a callback.

jsonwebtoken

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.

csrf-csrf

4.0.3

Double-submit-cookie CSRF protection for Express. csurf is archived and must not be used for new code.

express-rate-limit

8.7.0

Rate limiting middleware with an in-memory default store; exports ipKeyGenerator() for IPv6-safe keys.

fastify, @fastify/cookie, @fastify/jwt, @fastify/rate-limit

5.12.5, 11.1.2, 10.2.2, 11.2.0

The Fastify 5 counterparts; @fastify/jwt is built on fast-jwt.

Table 3. Node.js APIs used on this page
API Stability (Node.js 26 docs) Version notes

crypto.randomBytes(), crypto.randomUUID(), crypto.createHash(), crypto.timingSafeEqual(), crypto.scrypt()

2 Stable

Unchanged across 22, 24 and 26; the Argon2 functions arrived in v24.7.0 (see Crypto, Passwords & Secrets).

--env-file

Stable

No longer experimental in v24.10.0 and v22.21.0. The examples read every secret with node --env-file=.env.

fetch() and Headers.getSetCookie()

Stable

Used to drive the examples; getSetCookie() returns each Set-Cookie header separately.

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:

Generating secrets and installing the packages
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.

src/users.js: shared by every example on this page
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.

Table 4. Cookie attributes for a session or refresh cookie
Attribute What it does Recommended value

HttpOnly

Hides the cookie from document.cookie, so a cross-site scripting (XSS) bug cannot read and exfiltrate the identifier.

Always for session and refresh cookies.

Secure

Sends the cookie only over HTTPS (browsers also accept it on localhost). Prevents sniffing on plain-HTTP links.

Always in production.

SameSite

Controls whether the cookie accompanies cross-site requests. Strict never does; Lax does on top-level GET navigations; None always (and requires Secure).

Lax for the session cookie, Strict for a refresh cookie that is only used by your own page. Do not rely on the browser default.

Path and Domain

Path limits which request paths carry the cookie; a Domain attribute widens it to every subdomain, which makes any sibling host a possible attacker.

Omit Domain. Use a narrow Path for a cookie only one endpoint needs (the refresh endpoint below).

Max-Age / Expires

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.

__Host- name prefix

The browser refuses the cookie unless it is Secure, has Path=/ and no Domain. This blocks a sibling subdomain from overwriting it.

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.

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.

src/session-app.js: login, session read and logout with a Redis store
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();
Output (Node.js 26.10.0, 24.21.0 and 22.23.3 print the same text apart from the expiry time)
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-session sets the cookie only if Express considers the request HTTPS. Behind a TLS-terminating proxy that is true only when trust proxy is set and the proxy sends X-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, never secure: false. Set trust proxy to the exact number of hops (1 for one reverse proxy), not to true, which trusts a client-supplied X-Forwarded-For and 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’s ttl) is an idle timeout that touch extends on each request when rolling is on; add an absolute limit yourself by storing createdAt in 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) and clearCookie() 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.

Table 5. Passport building blocks
Piece Purpose

passport.use(strategy)

Registers a strategy. A passport-local verify callback receives (username, password, done); done(null, user) succeeds, done(null, false) fails, done(err) reports an infrastructure error.

passport.initialize()

Middleware that adds Passport to the request. Required.

passport.session()

Restores req.user from the session on each request. Only for session-based logins.

passport.serializeUser() and deserializeUser()

Convert a user to what is stored in the session (store only an id) and back to a user on every request.

passport.authenticate(name, options)

The route middleware that runs the strategy; use { session: false } for token strategies and failWithError: true to route failures to your own error handler.

req.login(), req.logout(callback), req.isAuthenticated()

Establish, end and test a login. Since 0.6.0 login and logout regenerate the session, and logout is asynchronous.

src/passport-session.js: Passport local strategy with a login session
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();
Output
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

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:

src/fixation.js: Is the session id rotated at login?
// 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();
Output
before login: GhpkxP
after login : H4vxra

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.

src/jwt-basics.js: issuing, pinned verification and five rejected tokens
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);
Output
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
Table 6. What each rejected token teaches
Attack Defence and why it works

"alg": "none" forgery

Version 9 rejects unsigned tokens by default, and pinning algorithms rejects them even in older code. Never include none in the allowed list.

Expired token

Set expiresIn on every token; verify() enforces exp. A token without an expiry is a permanent password. Use clockTolerance only for a few seconds of skew.

Wrong audience or issuer

Pin audience and issuer, so a token minted for another service or another environment is refused here.

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 algorithms to exactly what you issue (['RS256']), never to the algorithm named in the token’s own header.

decode() instead of verify()

decode() returns the claims of an unverified token (the example prints admin for a forged one). Use it only to inspect a token you have already verified.

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 jsonwebtoken 9.0.3: signing with EdDSA is rejected as an invalid algorithm; the jose library and fast-jwt (used by @fastify/jwt) support it.

  • Keep payloads small and non-sensitive, and treat claims like role as a cache that is stale until the token expires.

  • Do not store tokens in localStorage. Any script on the page can read them. Prefer an HttpOnly cookie, or keep the access token in memory and the refresh token in an HttpOnly cookie; 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.

src/jwt-app.js: passport-jwt for access tokens, a rotating refresh cookie with reuse detection
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();
Output
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 Authorization header 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.)

  • GET must never change state, so that the safe-method exemption in every library is actually safe.

Defend in layers, cheapest first:

Table 7. CSRF defence layers
Layer How it works Limits

SameSite=Lax or Strict on the cookie

The browser does not send the cookie on cross-site POST requests.

Same-site, not same-origin: a vulnerable sibling subdomain is not blocked. Older browsers ignore it.

Fetch Metadata (Sec-Fetch-Site)

Browsers label every request as same-origin, same-site, cross-site or none; the server refuses state-changing requests from cross-site.

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 __Host- prefix.

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.

src/csrf-sync.js: Fetch Metadata plus a session-bound synchronizer token
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();
Output
cookie name: __Host-bookshelf.sid
no token: 403
wrong token: 403
right token: 201
right token, cross-site (Fetch Metadata): 403

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.

src/csrf-double.js: stateless double-submit protection with csrf-csrf
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();
Output
cookies set: [ '__Host-psifi.x-csrf-token', 'connect.sid' ]
cookie only: 403
cookie + header: 201

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.

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.

Table 8. Rate limiting and lockout compared
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 trust proxy to see the real address.

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.

src/ratelimit.js: per-IP limiter plus per-account lockout with exponential back-off
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();
Output
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.

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.com lets 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 a POST.

  • 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().

src/email-verify.js: random, hashed, expiring, single-use verification tokens
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();
Output
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.

src/fastify-app.js: @fastify/jwt with pinned algorithms, a signed cookie and a per-route rate limit
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();
Output
login: 200 | cookie: bookshelf.seen true true Lax
with token: 200 reader
without token: 401
login failure 5: 429 retry-after 60

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

Table 9. Changes that affect authentication code written before them
Change Version and date Effect

Passport regenerates the session on login() and logout(); logout() is asynchronous

passport 0.6.0, 2022-05-20

Fixation protection became automatic. Code calling req.logout() with no callback needs updating. keepSessionInfo: true restores the old carry-over of session data.

jsonwebtoken 9: unsigned tokens rejected by default, RSA keys at least 2048 bits, key type must match the algorithm

jsonwebtoken 9.0.0, 2022-12-21

Old alg: none and key-confusion tricks stop working by default; pin algorithms anyway.

csurf archived

Repository notice

Use csrf-csrf (double submit) or a synchronizer-token approach; do not start new code on csurf.

express-rate-limit exports ipKeyGenerator() and validates misconfiguration (for example a permissive trust proxy)

Present in 8.7.0 as installed; the exact release that introduced it was not checked

Custom keyGenerator functions that use req.ip directly must apply the IPv6 subnet handling.

Express 5 forwards rejected promises from handlers to the error handler

express 5.0

async route handlers no longer need a wrapper to avoid unhandled rejections.

--env-file stable; Argon2 in node:crypto

Node.js 24.10.0 and 22.21.0; 24.7.0

Load secrets without dotenv, and hash passwords with Argon2id without a native addon.

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:

Table 10. Habits to replace
Habit shown Replace with

Secrets written in source files, or a JWT secret with a fixed fallback string

Read secrets from the environment (--env-file), generate them with crypto.randomBytes(), and stop the process at start-up when one is missing.

Tokens issued without expiresIn and verified without algorithms

Short expiry, pinned algorithms, issuer and audience, plus a rotating refresh token (see the JWT and refresh sections).

Session cookies without secure, httpOnly or sameSite

The attribute table above; add __Host- where possible and regenerate the session at login.

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 GET link that contains the email address

A random, hashed, expiring, single-use token confirmed with POST.

Low-cost password hashing (PBKDF2 with a low iteration count) and === comparison of hashes

Argon2id or scrypt, asynchronous APIs and crypto.timingSafeEqual(); OWASP’s PBKDF2-HMAC-SHA512 floor is 210,000 iterations. Details: Crypto, Passwords & Secrets.

req.logout() called without a callback, and session setup assumed to reuse the id (Passport before 0.6)

Pass a callback to logout(); Passport 0.7 rotates the id on login by itself.

Pitfalls and good practice

Table 11. Pitfalls
Pitfall Better practice

MemoryStore in production

Use Redis or a database store; the default store leaks memory and does not span processes.

trust proxy set to true

Set the exact hop count; otherwise X-Forwarded-For is attacker-controlled and rate limits are bypassed.

secure: true behind TLS termination without trust proxy

The cookie is silently never sent; configure the proxy headers.

Using jwt.decode() to authorise

Always verify() with pinned algorithms, issuer and audience.

Putting tokens in localStorage

HttpOnly cookie or in-memory access token.

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 GET

Link to a page; confirm with POST.

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 node:crypto primitives as shown in Crypto, Passwords & Secrets.

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.