Authentication & Authorization
|
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 Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The libraries were checked against
their package repositories on 2026-10-29: bcrypt 6.0.0, argon2 0.45.1, passport 0.7.0, passport-local 1.0.0,
express-session 1.19.0, jose 6.2.14, express-jwt 8.5.1, openid-client 6.8.8 and express-rate-limit 8.7.2.
Express ships no authentication at all. It gives the places where authentication belongs (middleware, the request
object, error handlers), and everything else comes from packages, so most of the work is choosing carefully and
configuring them safely. The page builds the identity layer of the Bookshelf API that grows across this section:
password storage, a session login for the server-rendered admin area, JWT access tokens with rotating refresh tokens for
the single-page client, OAuth and OpenID Connect login, and the guards that enforce roles and ownership. The examples
use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets: every secret is
read from process.env and must come from a secret manager or a deployment variable, never from the repository.
Theory is deliberately kept out of this page. The protocols are explained in the OAuth 2.0 and OpenID Connect guide, and the runtime-level view of passwords, sessions and tokens is in Node.js Authentication & Sessions. This page shows how those ideas map to Express middleware.
Authentication, authorization and where they run
Authentication answers "who is calling?" and produces an identity. Authorization answers "may this identity do this?" and
uses that identity plus the resource. In Express both are ordinary middleware: an authentication layer runs early and
sets req.user (Passport) or req.auth (express-jwt), and an authorization layer sits on the route that needs it and
either calls next() or ends the request with 401 or 403.
| Status | Meaning | Typical cause |
|---|---|---|
|
The request has no valid credentials. The response should carry a |
Missing, expired or invalid session cookie or token. |
|
The identity is known, and it is not allowed to do this. |
Wrong role, missing permission, resource owned by someone else. |
|
The resource is hidden from this identity on purpose. |
Reading another tenant’s private record, where a |
Two families of mechanisms cover almost every Express application, and a real system often uses both:
| Aspect | Server-side session (cookie) | Stateless token (JWT) |
|---|---|---|
What the client holds |
An opaque session id in an |
A signed token with claims, sent in the |
Where state lives |
In a store on the server (Redis, a database). |
In the token itself, plus a store for refresh tokens. |
Revocation |
Immediate: destroy the session. |
Only when the token expires, unless a deny list or short lifetimes are used. |
Best for |
Server-rendered pages and same-site browser applications. |
APIs consumed by mobile apps, other services and cross-origin single-page applications. |
Main risks |
CSRF, session fixation, an unbounded in-memory store. |
Algorithm confusion, tokens without expiry, tokens stored where scripts can read them. |
The sections below follow the order in which a system is built: store passwords, log users in, protect routes, issue tokens, add third-party login, and finally close the information leaks.
Password hashing
A password is never stored, logged or compared in clear text. It is stored as the output of a deliberately slow, salted
hash function, so that a stolen database cannot be reversed cheaply. General-purpose hashes (sha256, md5) are wrong
for this job because they are fast, and fast is exactly what an attacker wants. Three algorithms are acceptable today:
| Algorithm | Strengths | Points to watch |
|---|---|---|
argon2id |
Memory-hard, the current first choice of the OWASP password storage guidance. The |
Tune the memory, iterations and parallelism parameters to the server; a native add-on is compiled or downloaded at install time. |
bcrypt |
Mature and widely supported. Use a cost (work factor) of at least 12 and raise it as hardware improves. |
It reads only the first 72 bytes of the password, and the salt and cost travel inside the hash string. The |
scrypt |
Memory-hard and built into |
The parameters and the storage format are yours to manage; a library that wraps it is usually safer. |
Whatever the algorithm, always use the asynchronous API. The synchronous variants (bcrypt.hashSync,
crypto.scryptSync) run on the main thread and freeze every request for the duration of the hash, which is tens or
hundreds of milliseconds by design. The asynchronous variants run on the libuv thread pool; with the default pool of four
threads, a burst of logins can still queue, so rate-limit the login route (see User enumeration, brute force and information leaks) and raise
UV_THREADPOOL_SIZE only after measuring.
// passwords.mjs
import argon2 from 'argon2';
import bcrypt from 'bcrypt';
// argon2id with explicit parameters (memory in KiB). Start from the current OWASP minimums and measure on the
// production hardware: a hash should take roughly 100 to 500 ms.
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
};
export async function hashPassword(plain) {
return argon2.hash(plain, ARGON2_OPTIONS);
}
export async function verifyPassword(hash, plain) {
try {
return await argon2.verify(hash, plain);
} catch {
return false; // malformed hash or unsupported format: treat as a failed login
}
}
// True when the stored hash uses weaker parameters than the current ones, so the caller can rehash after a
// successful login and upgrade the database gradually.
export function needsRehash(hash) {
return argon2.needsRehash(hash, ARGON2_OPTIONS);
}
// bcrypt alternative: the cost is the second argument, and the salt is generated and embedded automatically.
const BCRYPT_COST = 12;
export async function hashPasswordBcrypt(plain) {
return bcrypt.hash(plain, BCRYPT_COST);
}
export async function verifyPasswordBcrypt(hash, plain) {
return bcrypt.compare(plain, hash);
}
Practical rules that apply to every algorithm:
-
Enforce a minimum length (at least 8 characters, 12 or more is better) and a generous maximum (at least 64 characters), but cap it. With bcrypt, reject or pre-hash inputs longer than 72 bytes deliberately instead of letting them be silently truncated, and cap every algorithm at a few hundred characters so that a huge body cannot be used to burn CPU.
-
Do not force composition rules or periodic rotation. Check candidates against a list of breached passwords instead.
-
Hash on registration and on every password change, and use
needsRehashafter a successful login to move old hashes to the current parameters. -
Never write passwords or hashes to logs, error messages or analytics events. Redact the
passwordfield in the request logger (see Debugging & Logging). -
A pepper (a secret added before hashing and kept outside the database) is optional hardening; if used, it belongs in a secret manager and needs a rotation plan.
The Bookshelf user store
The following examples share one small module. It keeps users in memory so that the page stays runnable; in the real
application the same functions are backed by the database layer described in the data pages. The important details are
what the user record contains, and that the only object ever sent to a client is the result of toPublicUser.
// users.mjs
import { randomUUID } from 'node:crypto';
import { hashPassword, verifyPassword } from './passwords.mjs';
const users = new Map(); // id -> { id, email, passwordHash, role, createdAt }
// A hash of a random password, computed once at startup. It lets the login code spend the same time on an unknown
// email as on a known one (see "User enumeration" below).
const DUMMY_HASH = await hashPassword(randomUUID());
export const ROLES = Object.freeze(['reader', 'editor', 'admin']);
export async function createUser({ email, password }) {
const normalized = email.trim().toLowerCase();
for (const user of users.values()) {
if (user.email === normalized) {
return null; // the caller decides how to report a duplicate without revealing it
}
}
const user = {
id: randomUUID(),
email: normalized,
passwordHash: await hashPassword(password),
role: 'reader', // fixed by the server, never taken from the request body
createdAt: new Date().toISOString(),
};
users.set(user.id, user);
return user;
}
export async function findById(id) {
return users.get(id) ?? null;
}
export async function findByEmail(email) {
const normalized = email.trim().toLowerCase();
for (const user of users.values()) {
if (user.email === normalized) {
return user;
}
}
return null;
}
// Verifies credentials in constant structure: the same work happens whether or not the account exists.
export async function authenticate(email, password) {
const user = await findByEmail(email);
const valid = await verifyPassword(user?.passwordHash ?? DUMMY_HASH, password);
return user && valid ? user : null;
}
// The only shape that leaves the server. Allow-list the fields; never spread the stored record.
export function toPublicUser(user) {
return { id: user.id, email: user.email, role: user.role };
}
Two design points matter more than the code. First, createUser copies only email and password from its input and
assigns the role itself: a registration handler that spreads req.body into the new record lets a caller send
an administrator role in the JSON body and promote themselves. This is mass assignment, and the fix is always an allow list, as described in
Validation & Input Handling. Second, serialisation: returning the
stored record from a route sends the password hash to the client. Build the response from toPublicUser, and add a
test that fails if the word passwordHash ever appears in a response body.
Session login with Passport
Passport is a small framework that standardises "authenticate this request" behind interchangeable strategies. Version 0.7.0 is the current release. It is not tied to Express, and it works through three pieces:
-
a strategy (
passport-localfor an email and password form) that verifies credentials and callsdone(err, user, info); -
passport.initialize()andpassport.session()middleware, which must be mounted afterexpress-session; -
serializeUseranddeserializeUser, which decide what is written into the session after login and howreq.useris rebuilt from it on every later request.
The login itself is req.login(user, callback), which passport.authenticate() calls for you on success. The
sequence below shows the whole round trip, including the session regeneration that Passport 0.6 introduced.
// session-login.mjs
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { authenticate, createUser, findById, toPublicUser } from './users.mjs';
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
// In production run behind a proxy that terminates TLS and set the trust proxy setting first, otherwise the
// secure cookie is never sent. See "Behind Proxies".
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
app.set('trust proxy', 1);
}
app.use(
session({
name: 'bookshelf.sid',
secret: process.env.SESSION_SECRET, // a long random value from the environment
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 8,
},
// store: a Redis or database store in production. The default MemoryStore leaks memory and is single process.
}),
);
app.use(passport.initialize());
app.use(passport.session());
// The strategy verifies credentials. Report every failure with the same message.
passport.use(
new LocalStrategy({ usernameField: 'email' }, async (email, password, done) => {
try {
const user = await authenticate(email, password);
if (!user) {
return done(null, false, { message: 'Invalid email or password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}),
);
// Store the smallest thing that identifies the user: its id, not the whole record.
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await findById(id);
done(null, user ?? false); // false clears a stale session instead of failing every request
} catch (error) {
done(error);
}
});
app.post('/register', async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string' || password.length < 12 || password.length > 256) {
return res.status(400).json({ message: 'A valid email and a password of 12 to 256 characters are required' });
}
await createUser({ email, password });
// Same answer whether or not the address already existed; confirm by email in a real system.
res.status(202).json({ message: 'If the address can be registered, a confirmation was sent' });
});
app.post('/login', (req, res, next) => {
passport.authenticate('local', (error, user, info) => {
if (error) {
return next(error);
}
if (!user) {
return res.status(401).json({ message: info?.message ?? 'Invalid email or password' });
}
// req.login regenerates the session id (Passport 0.6 and later) and then serializes the user.
req.login(user, (loginError) => {
if (loginError) {
return next(loginError);
}
res.json(toPublicUser(user));
});
})(req, res, next);
});
app.get('/me', (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: 'Authentication required' });
}
res.json(toPublicUser(req.user));
});
// POST, not GET, and with a callback: Passport 0.6 and later made logout asynchronous.
app.post('/logout', (req, res, next) => {
req.logout((error) => {
if (error) {
return next(error);
}
res.status(204).end();
});
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: Passport: Username and password
The pieces that most often go wrong:
-
Order.
passport.initialize()andpassport.session()must come aftersession()and after the body parsers, or the strategy reads an emptyreq.bodyandreq.usernever appears. A route registered before them is unauthenticated by construction. -
serializeUseranddeserializeUsersize. WhateverserializeUserstores is written to the session store and read back on every request, anddeserializeUserruns on every request for an authenticated user. Store the id only, and keep the lookup cheap or cache it. ReturningfalsefromdeserializeUserfor a deleted account logs the user out quietly. -
req.logout(callback). In Passport 0.6 and later the call takes a callback because the session is regenerated asynchronously. Callingreq.logout()without a callback throws, which is the error that old tutorials produce after an upgrade. Logout also regenerates the session so that the old id cannot be reused. -
Session regeneration at login. Passport 0.6 and later regenerates the session on
req.login()to prevent session fixation: an attacker who planted a known session id before login gets nothing. The optionkeepSessionInfo: truecopies the previous session data across and should be used only when that data (a shopping cart, a return URL) is worth the exposure. -
Logout is a state change. A
GET /logoutlink can be triggered by any page through an image tag or a redirect, which is cross-site request forgery. UsePOSTand a CSRF token, or at leastSameSite=Laxcookies, as described in Security Best Practices. -
A failed login is not an error. The strategy calls
done(null, false, info)for wrong credentials anddone(err)only for faults such as a database outage. Mixing them turns a typo into a500. -
Cookie attributes.
httpOnly,secureandsameSiteare covered with the session store options in Cookies & Sessions; thesecureflag silently drops the cookie behind a TLS-terminating proxy unlesstrust proxyis configured (see Behind Proxies).
Passport also separates strategies from the session: the same strategy works with the session option set to false for an API that
authenticates every request, which is how passport-jwt and HTTP Basic or Bearer strategies are normally used. For
bearer tokens the next section shows the leaner approach of verifying the token directly with jose or express-jwt.
Route guards and authorization
A guard is a middleware that reads the identity established earlier and decides whether to continue. Keep guards small, single-purpose and composable, and mount them at the router or route level so that a new route cannot be added without passing through them. A deny-by-default structure, where the router is protected as a whole and public routes are the exception, is safer than adding guards one route at a time.
// guards.mjs
import { ROLES } from './users.mjs';
// Authentication: is there an identity at all? Works with Passport (req.user) and with the JWT middleware below.
export function requireAuthentication(req, res, next) {
const identity = req.user ?? req.auth;
if (!identity) {
res.set('WWW-Authenticate', 'Bearer realm="bookshelf"');
return res.status(401).json({ status: 401, message: 'Authentication required' });
}
next();
}
// Role-based access control: a role has a fixed set of permissions, and routes ask for a permission, not a role name.
const PERMISSIONS = Object.freeze({
reader: ['book:read', 'review:create'],
editor: ['book:read', 'book:create', 'book:update', 'review:create'],
admin: ['book:read', 'book:create', 'book:update', 'book:delete', 'review:create', 'review:delete', 'user:manage'],
});
export function roleOf(req) {
return req.user?.role ?? req.auth?.role;
}
export function can(role, permission) {
return ROLES.includes(role) && PERMISSIONS[role].includes(permission);
}
export function requirePermission(permission) {
return (req, res, next) => {
if (!can(roleOf(req), permission)) {
return res.status(403).json({ status: 403, message: 'You do not have permission to do this' });
}
next();
};
}
// Ownership: the identity may act on this resource only when it owns it, or is an administrator.
export function requireOwnerOrAdmin(loadOwnerId) {
return async (req, res, next) => {
const ownerId = await loadOwnerId(req); // may throw; Express 5 forwards the rejection
if (ownerId === undefined) {
return res.status(404).json({ status: 404, message: 'Not found' });
}
const subject = req.user?.id ?? req.auth?.sub;
if (ownerId !== subject && roleOf(req) !== 'admin') {
// 404 instead of 403 when the existence of the resource is itself private.
return res.status(404).json({ status: 404, message: 'Not found' });
}
next();
};
}
Source: Express: Using middleware
The guards compose at the route:
// reviews-router.mjs
import { Router } from 'express';
import { requireAuthentication, requireOwnerOrAdmin, requirePermission } from './guards.mjs';
const reviews = new Map([['r1', { id: 'r1', bookId: 'b1', authorId: 'u1', text: 'Great' }]]);
export const reviewsRouter = Router();
// Everything below this line requires a login. Public routes are registered on another router.
reviewsRouter.use(requireAuthentication);
reviewsRouter.post('/', requirePermission('review:create'), (req, res) => {
const { bookId, text } = req.body ?? {};
const review = { id: `r${reviews.size + 1}`, bookId, authorId: req.user?.id ?? req.auth.sub, text };
reviews.set(review.id, review);
res.status(201).json(review);
});
// The author may edit their own review; an administrator may edit any review.
reviewsRouter.patch(
'/:id',
requireOwnerOrAdmin((req) => reviews.get(req.params.id)?.authorId),
(req, res) => {
const review = reviews.get(req.params.id);
// Allow-list the editable fields so that authorId and bookId cannot be reassigned by the request body.
if (typeof req.body?.text === 'string') {
review.text = req.body.text;
}
res.json(review);
},
);
reviewsRouter.delete('/:id', requirePermission('review:delete'), (req, res) => {
reviews.delete(req.params.id);
res.status(204).end();
});
Source: Express: Routing
Rules that keep authorization correct:
-
Never trust the client for the role or the owner. Read the role from the server-side record or from a verified token claim, and the owner from the stored resource, never from
req.body, a hidden form field or a header. -
Check ownership against the stored resource, not the URL. Knowing the id in
/reviews/:idproves nothing. Insecure direct object references, where any logged-in user can read or change any id, are the most common authorization bug. -
Authorize at the data layer too. Filtering the query by
ownerIdis stronger than loading a row and then checking it, and it avoids reading data the caller must not see. -
Prefer permissions to role names in routes. a guard that asks for the delete permission survives a change in what the roles are, and the mapping lives in one table. Roles in tokens should be treated as a cache of the server’s view, which is why access tokens are kept short.
-
Order is policy. A guard registered after a handler does nothing, and
router.use(guard)protects only routes registered after it. Test that each protected route returns401without credentials and403with the wrong role (see Testing). -
Do not leak through errors. Use the same status for "missing" and "forbidden" when existence is sensitive, and return the JSON error shape described in Error Handling.
For fine-grained, relationship-based or attribute-based rules (a document shared with specific people, a tenant boundary),
a role table stops being enough. A policy library such as CASL, or an external authorization service, keeps the rules
out of route files; the Express integration is still a middleware that calls it and ends the request with 403.
Stateless authentication with JWT
A JSON Web Token (JWT) is a signed set of claims. A server that holds the verification key can authenticate a request without looking anything up, which suits APIs, mobile clients and service-to-service calls. That convenience is paid for by two limitations: a token cannot be revoked before it expires, and anything in its payload is readable by the client (it is signed, not encrypted). The structure and the registered claims are explained in JWT and JOSE, and the distinction between access and ID tokens in ID Tokens vs Access Tokens. The practical rules for Express are:
-
Pin the algorithm. Pass an explicit
algorithmslist to the verifier and never let the token header choose. Acceptingnone, or accepting an RSA public key as an HMAC secret (algorithm confusion), has broken many implementations.express-jwt8 refuses to start without analgorithmsoption, andjoserequires a key whose type matches the algorithm. -
Always set an expiry (
exp) and keep it short, usually 5 to 15 minutes for an access token, and verifyiss(issuer) andaud(audience) so that a token minted for another service is rejected. -
Use a strong key. An HMAC secret for HS256 should be at least 32 random bytes. Prefer asymmetric signing (RS256, ES256 or EdDSA) when other services verify tokens, so that they hold only the public key, published as a JWKS.
-
Keep the payload small and non-sensitive. Put an opaque subject, the role and the scopes in it; leave out emails, hashes and personal data.
-
Send it in the
Authorization: Bearerheader. Do not invent a scheme name, and do not keep the token inlocalStorageorsessionStorage, where any injected script can read it. See Where the client keeps tokens.
Issuing and verifying with jose
jose is a dependency-free, standards-based library that works on Node.js and other runtimes with the same API. The
module below issues an access token and verifies it, with the algorithm, issuer, audience and required claims pinned.
// tokens.mjs
import { randomUUID } from 'node:crypto';
import { SignJWT, jwtVerify, errors } from 'jose';
const ISSUER = 'https://api.bookshelf.example';
const AUDIENCE = 'bookshelf-api';
const ACCESS_TTL = '10m';
// HS256 needs at least 32 bytes of secret. Fail at startup rather than sign with a weak key.
function loadKey() {
const secret = process.env.JWT_SECRET;
if (!secret || Buffer.byteLength(secret) < 32) {
throw new Error('JWT_SECRET must be set to at least 32 bytes');
}
return new TextEncoder().encode(secret);
}
const key = loadKey();
export async function signAccessToken(user) {
return new SignJWT({ role: user.role })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setSubject(user.id)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime(ACCESS_TTL)
.setJti(randomUUID())
.sign(key);
}
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'], // pinned: the token header cannot select another algorithm
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'],
clockTolerance: '5s',
});
return payload;
}
export { errors as joseErrors };
Source: panva/jose: SignJWT
The authentication middleware extracts the bearer token, verifies it and exposes the claims. It supports an optional mode for routes that behave differently for anonymous and signed-in callers (a book page that shows a "your review" box only when the reader is known).
// jose-middleware.mjs
import { joseErrors, verifyAccessToken } from './tokens.mjs';
function bearerToken(req) {
const header = req.get('authorization');
if (!header) {
return null;
}
const [scheme, token, ...rest] = header.split(' ');
return scheme.toLowerCase() === 'bearer' && token && rest.length === 0 ? token : null;
}
export function authenticateJwt({ optional = false } = {}) {
return async (req, res, next) => {
const token = bearerToken(req);
if (!token) {
if (optional) {
return next();
}
res.set('WWW-Authenticate', 'Bearer realm="bookshelf"');
return res.status(401).json({ status: 401, message: 'Missing bearer token' });
}
try {
const claims = await verifyAccessToken(token);
req.auth = { sub: claims.sub, role: claims.role, jti: claims.jti };
next();
} catch (error) {
if (optional && !(error instanceof joseErrors.JWTExpired)) {
// A bad token on an optional route is treated as anonymous; an expired one tells the client to refresh.
return next();
}
const expired = error instanceof joseErrors.JWTExpired;
res.set(
'WWW-Authenticate',
`Bearer realm="bookshelf", error="invalid_token", error_description="${expired ? 'token expired' : 'invalid token'}"`,
);
return res.status(401).json({ status: 401, message: expired ? 'Token expired' : 'Invalid token' });
}
};
}
Source: RFC 6750: Bearer Token Usage
The express-jwt middleware
express-jwt 8.5.1 wraps jsonwebtoken and is the shortest route when a standard shared-secret or JWKS setup is
enough. It puts the decoded payload on req.auth (not req.user, which was the name before version 7), and throws an
UnauthorizedError with a code such as credentials_required, invalid_token or revoked_token. Express 5 delivers
that error to the error handlers like any other, so the translation to JSON is one handler.
// express-jwt-app.mjs
import express from 'express';
import { expressjwt } from 'express-jwt';
const app = express();
const jwtOptions = {
secret: process.env.JWT_SECRET, // string, Buffer, or an async function (req, token) for key lookup
algorithms: ['HS256'], // required in express-jwt 8: no default, no token-driven selection
issuer: 'https://api.bookshelf.example',
audience: 'bookshelf-api',
};
// Public routes first: nothing here needs a token.
app.get('/api/v1/books', expressjwt({ ...jwtOptions, credentialsRequired: false }), (req, res) => {
// req.auth is undefined for anonymous callers and an object for signed-in ones.
res.json({ personalised: Boolean(req.auth), books: [] });
});
// Everything under /api/v1/me requires a valid token.
app.use('/api/v1/me', expressjwt(jwtOptions));
app.get('/api/v1/me', (req, res) => {
res.json({ id: req.auth.sub, role: req.auth.role });
});
// Revocation hook: reject tokens whose jti was denied, for example after a logout or a password change.
const revoked = new Set();
app.use(
'/api/v1/admin',
expressjwt({
...jwtOptions,
isRevoked: async (req, token) => revoked.has(token.payload.jti),
}),
);
// express-jwt errors reach the default HTML handler unless a JSON handler translates them.
app.use((err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
res.set('WWW-Authenticate', `Bearer realm="bookshelf", error="invalid_token"`);
return res.status(err.status ?? 401).json({ status: 401, code: err.code, message: err.message });
}
next(err);
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: auth0/express-jwt
Use credentialsRequired: false for the optional-authentication pattern, as above: a request without a token passes with
no req.auth, whereas a request with an invalid token is still rejected. The getToken option reads the token from
somewhere other than the Authorization header (a cookie, say), and requestProperty renames req.auth. In a router
that is also used with Passport sessions, keep the two property names distinct so a guard can tell which mechanism
authenticated the caller.
Asymmetric keys and JWKS
When another party issues the tokens (an identity provider, or your own authentication service), the API should hold only
public keys. jose fetches and caches a JSON Web Key Set and selects the key by the token’s kid:
// jwks-verification.mjs
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'), {
cooldownDuration: 30_000, // do not refetch more often than this when an unknown kid appears
cacheMaxAge: 600_000,
});
export async function verifyIdpToken(token) {
const { payload } = await jwtVerify(token, jwks, {
algorithms: ['RS256', 'ES256'], // an allow list of asymmetric algorithms only: HS256 is not accepted here
issuer: 'https://auth.example.com/',
audience: 'bookshelf-api',
});
return payload;
}
Source: panva/jose: createRemoteJWKSet
Listing only asymmetric algorithms there is the algorithm-confusion defence: even if an attacker signs a token with HS256
using the public key as the secret, the verifier will not accept the algorithm. The same configuration with express-jwt
uses the jwks-rsa package as the secret function.
Refresh-token rotation
Short access tokens are only usable if clients can get new ones without asking for the password again. A refresh token does that. It is long-lived, so it is more valuable to an attacker and must be handled with more care than the access token:
-
Make it opaque and random (32 or more bytes from
node:crypto), not a JWT. It carries no claims, because the server always looks it up. -
Store only a hash of it (SHA-256 is enough for a high-entropy random value) with the user id, a family id, an expiry and a used flag. A database leak then does not hand out working tokens.
-
Rotate on every use. Each refresh invalidates the presented token and returns a new one in the same family.
-
Detect reuse. If an already-used token is presented again, one of two parties holds a stolen copy. Revoke the whole family and force a new login.
-
Deliver it in an
HttpOnly,Secure,SameSite=Strictcookie scoped withpathto the refresh endpoint only, so it is not sent with ordinary API calls and scripts cannot read it.
// auth-router.mjs
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import cookieParser from 'cookie-parser';
import express from 'express';
import { authenticate, findById, toPublicUser } from './users.mjs';
import { signAccessToken } from './tokens.mjs';
const REFRESH_TTL_MS = 1000 * 60 * 60 * 24 * 14;
const REFRESH_COOKIE = 'bookshelf_refresh';
// hash -> { userId, familyId, expiresAt, used }. A database table with an index on the hash in a real application.
const refreshTokens = new Map();
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
function issueRefreshToken(userId, familyId = randomUUID()) {
const token = randomBytes(32).toString('base64url');
refreshTokens.set(sha256(token), { userId, familyId, expiresAt: Date.now() + REFRESH_TTL_MS, used: false });
return token;
}
function revokeFamily(familyId) {
for (const [hash, record] of refreshTokens) {
if (record.familyId === familyId) {
refreshTokens.delete(hash);
}
}
}
function setRefreshCookie(res, token) {
res.cookie(REFRESH_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/auth', // sent only to the authentication endpoints
maxAge: REFRESH_TTL_MS,
});
}
export const authRouter = express.Router();
authRouter.use(cookieParser());
authRouter.post('/login', express.json(), async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ message: 'email and password are required' });
}
const user = await authenticate(email, password);
if (!user) {
return res.status(401).json({ message: 'Invalid email or password' });
}
setRefreshCookie(res, issueRefreshToken(user.id));
res.json({ accessToken: await signAccessToken(user), user: toPublicUser(user) });
});
authRouter.post('/refresh', async (req, res) => {
const presented = req.cookies?.[REFRESH_COOKIE];
const record = presented ? refreshTokens.get(sha256(presented)) : undefined;
if (!record || record.expiresAt < Date.now()) {
res.clearCookie(REFRESH_COOKIE, { path: '/auth' });
return res.status(401).json({ message: 'Invalid refresh token' });
}
if (record.used) {
// Reuse of a rotated token: assume theft and end the whole session family.
revokeFamily(record.familyId);
res.clearCookie(REFRESH_COOKIE, { path: '/auth' });
return res.status(401).json({ message: 'Refresh token reuse detected' });
}
const user = await findById(record.userId);
if (!user) {
revokeFamily(record.familyId);
return res.status(401).json({ message: 'Invalid refresh token' });
}
record.used = true;
setRefreshCookie(res, issueRefreshToken(user.id, record.familyId));
res.json({ accessToken: await signAccessToken(user) });
});
authRouter.post('/logout', (req, res) => {
const presented = req.cookies?.[REFRESH_COOKIE];
const record = presented ? refreshTokens.get(sha256(presented)) : undefined;
if (record) {
revokeFamily(record.familyId);
}
res.clearCookie(REFRESH_COOKIE, { path: '/auth' });
res.status(204).end();
});
Source: Express API: res.cookie
The pattern is deliberately split in two: an access token held in memory by the client, and a refresh cookie the script
cannot read. A cookie-based refresh endpoint is exposed to cross-site requests, which SameSite=Strict, a narrow
path and a check on the Origin header together contain; the CORS rules for the endpoint are in
CORS. The browser-side considerations are described in
Browser-Based Apps, and the access and refresh token lifecycle in
Access and Refresh Tokens.
Where the client keeps tokens
| Storage | Risk | Verdict |
|---|---|---|
|
Readable by any script on the page, so one cross-site scripting bug or a compromised dependency exfiltrates every token. |
Do not store tokens here. |
JavaScript memory (a variable or a closure) |
Lost on reload, which is fixed by calling |
Good for the short-lived access token. |
|
Not readable by scripts. Sent automatically, so CSRF must be considered. |
Right for the refresh token, and for a session id. |
Mobile secure storage (Keychain, Keystore) |
Platform-protected. |
Right for native apps. |
OAuth 2.0 and OpenID Connect login
"Sign in with Google" or with a corporate identity provider delegates authentication. The application never sees the
password: it redirects to the provider, the user authenticates there, and the provider returns an authorization code
that the server exchanges for tokens. The modern, recommended flow is the authorization code flow with PKCE (Proof
Key for Code Exchange), explained in Authorization Code and PKCE. OAuth 2.0
itself only authorizes access; OpenID Connect adds an ID token and a standard userinfo endpoint so the application
can learn who the user is (OpenID Connect). The claim and scope vocabulary is in
Scopes, Claims and Permissions, the federation patterns in
Social Login and Federation, and logout across applications in
Logout and Session Management. Never implement the protocol by
hand: use a maintained client, and let it do discovery, state, nonce, PKCE and ID-token validation.
OpenID Connect with openid-client
openid-client 6 is a certified OpenID Connect client built on web standards. Its version 6 API is function-based
(import * as client), and replaced the Issuer and Client classes of earlier versions. The routes below start a
login and handle the callback; the session holds only the short-lived verifier, state and nonce.
// oidc-login.mjs
import express from 'express';
import session from 'express-session';
import * as client from 'openid-client';
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' },
}),
);
// Discovery fetches the provider metadata (endpoints, keys, supported algorithms) once at startup.
const config = await client.discovery(
new URL(process.env.OIDC_ISSUER), // for example https://accounts.google.com
process.env.OIDC_CLIENT_ID,
process.env.OIDC_CLIENT_SECRET, // omit for a public client
);
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI; // exactly the value registered with the provider
app.get('/auth/login', async (req, res) => {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
// Keep them server-side for the callback.
req.session.oidc = { codeVerifier, state, nonce };
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
nonce,
});
res.redirect(url.href);
});
app.get('/auth/callback', async (req, res, next) => {
try {
const pending = req.session.oidc;
if (!pending) {
return res.status(400).json({ message: 'No login in progress' });
}
delete req.session.oidc;
// Validates state, the authorization response, the PKCE verifier, the ID token signature, issuer, audience,
// expiry and nonce.
const currentUrl = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
});
const claims = tokens.claims();
// Match on the stable pair (issuer, subject), never on email alone. Map it to a local user and role.
const localUser = { id: `${claims.iss}|${claims.sub}`, email: claims.email, role: 'reader' };
// Regenerate the session on privilege change, as Passport does at login.
req.session.regenerate((error) => {
if (error) {
return next(error);
}
req.session.user = localUser;
res.redirect('/');
});
} catch (error) {
next(error);
}
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: panva/openid-client
Everything the books call "just use the profile" has details that matter:
-
Identify by issuer and subject. The
subclaim is stable and unique per provider; an email address can change, be reassigned, or be unverified. Link accounts through the verifiedemail_verifiedclaim only deliberately, to avoid account takeover when two providers disagree about an address. -
Handle the first login. A callback that finds no local user must create one (or reject the login) rather than call the done function with
nulland a truthy flag, which is the mistake in older tutorials. -
Redirect URIs are exact. Register the full callback URL and compare it exactly; open redirects at the provider are a classic attack. Behind a proxy, build the public URL from configuration, not from
req.protocolandreq.hostnameunlesstrust proxyis set correctly. -
Request the least scope. Ask for
openid email profileand nothing else unless the application calls the provider’s APIs. -
Token exchange stays on the server. The client secret and the tokens never reach the browser. Store provider access or refresh tokens, when they are needed, encrypted at rest.
passport-google-oauth20
When the application already uses Passport sessions, the provider strategies plug into the same serializeUser and
deserializeUser. The listing below is therefore not a standalone app: it adds the Google routes to session-login.mjs
from Session login with Passport, which already mounts session(), passport.initialize() and passport.session() and defines
the serializers. Without them, state: true fails at the first login, because the strategy keeps the state value in the
session. passport-google-oauth20 implements the Google OAuth 2.0 login (2.0.0 is the latest release, and it
has changed little for years, so check that it still fits your needs before choosing it over openid-client).
// added to session-login.mjs, after passport.session() and the serializers
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
state: true, // generate and check a state value to defend against login CSRF
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create the local user from profile.id. Always call done with a user object or false, never null,
// for a user that should be logged in; the first login has no row yet, so create it here.
const user = { id: `google|${profile.id}`, email: profile.emails?.[0]?.value, role: 'reader' };
done(null, user);
} catch (error) {
done(error);
}
},
),
);
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get(
'/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login', failureMessage: true }),
(req, res) => {
res.redirect('/');
},
);
For a resource server that only validates tokens issued by a provider, none of this is needed: verify the access token
with jose and createRemoteJWKSet as shown above, or call the provider’s introspection endpoint for opaque tokens
(Opaque Tokens, Introspection and Revocation). The best
practices that apply to every flow are collected in
OAuth Security Best Practices.
User enumeration, brute force and information leaks
An attacker can learn which addresses are registered by watching how the application responds. Three channels leak, and each needs a different fix:
| Channel | How it leaks | Fix |
|---|---|---|
Message text |
"No such user" versus "Wrong password"; "email already registered" at sign-up. |
Use one message for all login failures and a neutral answer for registration and password reset ("if the address exists, an email was sent"). |
Status and response shape |
Different status codes or different JSON bodies for known and unknown accounts. |
Return identical responses, and complete the confirmation step by email. |
Timing |
A known user costs a password hash, an unknown one returns immediately. |
Verify against a dummy hash when the account does not exist, as |
Throttle the login, registration, refresh and password-reset routes. express-rate-limit 8 counts requests per key in
a window. Put two limiters on the login route: one keyed by IP address, which slows one client trying many accounts, and
one keyed by the normalized target account, so a distributed attack on one account is also slowed. A single combined
ip|email key does not do that, because every attacking address gets its own budget. The IP address is only meaningful
when trust proxy is configured for the actual proxy chain, and a custom key must pass it through ipKeyGenerator(),
which groups IPv6 addresses by subnet (a raw req.ip fails the library’s validation).
// rate-limit.mjs
import { ipKeyGenerator, rateLimit } from 'express-rate-limit';
const common = {
standardHeaders: 'draft-8',
legacyHeaders: false,
skipSuccessfulRequests: true, // only failed logins consume the budget
handler: (req, res) => {
res.status(429).json({ status: 429, message: 'Too many attempts, try again later' });
},
};
// One client trying many accounts.
export const loginLimiterByIp = rateLimit({
...common,
windowMs: 15 * 60 * 1000,
limit: 10, // failed attempts per window and address
keyGenerator: (req) => ipKeyGenerator(req.ip),
});
// Many addresses trying one account. Looser, because anyone can spend it to lock the real user out.
export const loginLimiterByAccount = rateLimit({
...common,
windowMs: 60 * 60 * 1000,
limit: 20, // failed attempts per window and account
keyGenerator: (req) => `account:${String(req.body?.email ?? '').trim().toLowerCase()}`,
});
// app.post('/login', loginLimiterByIp, loginLimiterByAccount, ...)
Source: express-rate-limit
The default in-memory store counts per process, so behind several instances use a shared store (Redis). Other leaks worth
closing: never return the password hash, reset token or internal ids in any response (build the response from an allow
list); keep stack traces out of responses in production (see
Error Handling); do not put tokens in URLs, which end up in logs and Referer
headers; and compare secrets with crypto.timingSafeEqual rather than ===.
The official auth example
The expressjs/express repository ships examples/auth, a server-rendered application that logs users in with a
username and password and protects pages with a restrict middleware. It is a good small reference for the pattern of
this page, and it shows the same ideas in plain Express: a salted PBKDF2 hash stored per user, req.session.regenerate() on login so the session id changes, a restrict middleware that redirects
anonymous visitors, and a logout that destroys the session. It exists to demonstrate the mechanics rather than to be
copied: the user table is an in-memory object, the hashing parameters are older, and there is no CSRF protection, rate
limiting or persistent store.
Source: expressjs/express: examples/auth
Express 4 and Express 5 differences that touch authentication
-
Rejected promises reach the error handlers in Express 5. An
asyncguard or login handler that throws needs notry/catch, and the JWT and Passport error paths end in the same handler. In Express 4, anasyncmiddleware that rejects hangs the request, so guards must catch and callnext(err)(or use a wrapper). -
req.bodyis no longer initialised to an empty object in Express 5. It isundefinedwhen no parser ran or no body was sent, so write the fallback in the code and mountexpress.json()andexpress.urlencoded()before the authentication routes. -
express.urlencodeddefaults toextended: false. Form logins do not need the nested form ofqs. -
The magic
backargument ofres.redirectwas removed in Express 5. Read theRefererheader yourself and validate the target, or better, use a fixed return URL, to avoid an open redirect after login. -
Client address and protocol depend on
trust proxyin both versions.req.ipandreq.protocolfeed callback URLs and rate-limit keys, so the setting must reflect the real proxy chain. -
Route path syntax. Express 5 uses
path-to-regexp8; authentication routes with patterns such as/auth/*need a named wildcard instead of a bare asterisk.
What changed recently
| Change | Effect on this page |
|---|---|
Passport 0.6 (2022) and 0.7 (2023) |
|
express-jwt 7 and 8 |
The decoded payload moved from |
|
Rewritten as functions on web-standard APIs ( |
|
Runs on web-standard APIs across runtimes. Check the changelog when upgrading, since major versions changed the supported runtimes and key handling. |
Express 5.3 (2026-10-09) |
Dependency and security updates, including the |
|
The CSRF middleware is deprecated; session-based logins protect state-changing routes with a maintained alternative (see Security Best Practices). |
Where the books are outdated
The four books consulted for this section target Express 4.0 to 4.18, and their authentication chapters predate the changes above. They are useful for the overall shape of a login flow and should not be followed for the details:
| Older practice | Where it appears | Current behaviour |
|---|---|---|
Password hashing with the |
Both Monteiro books |
|
|
Sharma, both Monteiro books |
Passport 0.6 and later requires |
Session fixation not considered at login. |
Sharma, both Monteiro books |
|
|
Sharma, both Monteiro books |
Use |
JWTs with no expiry, a custom |
Sharma |
Short |
No refresh and no revocation (acknowledged by the author). |
Bugl |
Refresh-token rotation with reuse detection, and a |
|
Bugl, chapter 6 |
|
A role or the author field copied from |
Sharma, chapter 6; Bugl, chapter 6 |
Assign roles on the server and allow-list updatable fields. |
The password hash returned in API responses. |
Sharma, chapter 6 |
Build every response from an allow-listed public shape. |
A first OAuth login that passes |
Sharma, chapter 7 |
Create the local user on the first login, identify by issuer and subject, and use authorization code with PKCE and
|
Middleware registered before |
Monteiro 2014, chapter 4 |
Mount |
Session, JWT and OAuth secrets committed to the repository (including |
Sharma, both Monteiro books, Bugl |
Read secrets from the environment or a secret manager, fail at startup if they are missing, and rotate any secret that was ever committed. |
No rate limiting, no input validation and no CSRF protection on the login routes. |
All four books |
Add |
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
|
|
Mount |
|
Old code calls |
Pass a callback to |
The cookie is set in development and missing in production. |
|
Set |
Sessions are lost on restart or differ between instances. |
The default |
Use a shared store such as Redis or a database. |
Any token is accepted, or a forged token passes. |
No |
Pin the algorithms and use a key that matches their type. |
Users are logged out every few minutes with no way back. |
A short access token and no refresh flow. |
Add the refresh endpoint and retry the request once on |
|
The guard conflates authentication and authorization. |
Return |
The role in the token is out of date after a change. |
Roles cached in a long-lived token. |
Keep access tokens short, or re-read the role from the database for sensitive operations. |
Bearer tokens appear in logs. |
The request logger prints headers or the URL carries the token. |
Redact |
A |
The unique constraint error reaches the default handler. |
Translate it to the neutral registration response or |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Cookies & Sessions, Security Best Practices, Validation & Input Handling, Error Handling, CORS, Testing, Node.js Authentication & Sessions
-
OAuth and OpenID Connect theory: OAuth 2.0 and OpenID Connect, JWT and JOSE, OpenID Connect, Authorization Code and PKCE