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

401 Unauthorized

The request has no valid credentials. The response should carry a WWW-Authenticate header naming the scheme.

Missing, expired or invalid session cookie or token.

403 Forbidden

The identity is known, and it is not allowed to do this.

Wrong role, missing permission, resource owned by someone else.

404 Not Found

The resource is hidden from this identity on purpose.

Reading another tenant’s private record, where a 403 would confirm that it exists.

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 HttpOnly cookie.

A signed token with claims, sent in the Authorization header or a cookie.

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.

flowchart LR A["Request"] --> B["Parse cookies, body, headers"] B --> C["Authenticate: session, JWT or OAuth callback"] C -->|"no valid identity"| D["401 and WWW-Authenticate"] C -->|"identity on req.user or req.auth"| E["Authorize: role, permission, ownership"] E -->|"not allowed"| F["403 or 404"] E -->|"allowed"| G["Route handler"] G --> H["Response"]

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 argon2 package defaults to argon2id.

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 bcrypt package is a native add-on; bcryptjs is a pure JavaScript port that is slower.

scrypt

Memory-hard and built into node:crypto, so it needs no dependency.

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 needsRehash after 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 password field 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-local for an email and password form) that verifies credentials and calls done(err, user, info);

  • passport.initialize() and passport.session() middleware, which must be mounted after express-session;

  • serializeUser and deserializeUser, which decide what is written into the session after login and how req.user is 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.

sequenceDiagram participant B as Browser participant E as Express participant P as Passport local strategy participant S as Session store B->>E: POST /login with email and password E->>P: passport.authenticate("local") P->>P: Look up the user and verify the hash P-->>E: user, or false with a message E->>S: req.login: regenerate the session id S-->>E: new empty session E->>S: serializeUser writes the user id into the new session E-->>B: 302 to /admin and Set-Cookie with the new session id B->>E: GET /admin with the cookie E->>S: Load the session by id E->>E: deserializeUser rebuilds req.user from the stored id E-->>B: 200 for the admin page B->>E: POST /logout with the CSRF token E->>S: req.logout: remove the user and regenerate the session E-->>B: 302 to /login
// 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;
  }
});

The pieces that most often go wrong:

  • Order. passport.initialize() and passport.session() must come after session() and after the body parsers, or the strategy reads an empty req.body and req.user never appears. A route registered before them is unauthenticated by construction.

  • serializeUser and deserializeUser size. Whatever serializeUser stores is written to the session store and read back on every request, and deserializeUser runs on every request for an authenticated user. Store the id only, and keep the lookup cheap or cache it. Returning false from deserializeUser for 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. Calling req.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 option keepSessionInfo: true copies 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 /logout link can be triggered by any page through an image tag or a redirect, which is cross-site request forgery. Use POST and a CSRF token, or at least SameSite=Lax cookies, as described in Security Best Practices.

  • A failed login is not an error. The strategy calls done(null, false, info) for wrong credentials and done(err) only for faults such as a database outage. Mixing them turns a typo into a 500.

  • Cookie attributes. httpOnly, secure and sameSite are covered with the session store options in Cookies & Sessions; the secure flag silently drops the cookie behind a TLS-terminating proxy unless trust proxy is 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();
  };
}

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();
});

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/:id proves 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 ownerId is 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 returns 401 without credentials and 403 with 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:

  1. Pin the algorithm. Pass an explicit algorithms list to the verifier and never let the token header choose. Accepting none, or accepting an RSA public key as an HMAC secret (algorithm confusion), has broken many implementations. express-jwt 8 refuses to start without an algorithms option, and jose requires a key whose type matches the algorithm.

  2. Always set an expiry (exp) and keep it short, usually 5 to 15 minutes for an access token, and verify iss (issuer) and aud (audience) so that a token minted for another service is rejected.

  3. 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.

  4. 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.

  5. Send it in the Authorization: Bearer header. Do not invent a scheme name, and do not keep the token in localStorage or sessionStorage, 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 };

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' });
    }
  };
}

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;
  }
});

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;
}

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=Strict cookie scoped with path to the refresh endpoint only, so it is not sent with ordinary API calls and scripts cannot read it.

sequenceDiagram participant C as Single-page client participant A as Express API participant D as Token store C->>A: POST /auth/login with email and password A->>D: Save the hash of refresh token R1 in family F A-->>C: Access token A1 in the body and R1 in an HttpOnly cookie C->>A: GET /api/v1/me with Bearer A1 A-->>C: 200 Note over C,A: A1 expires after a few minutes C->>A: GET /api/v1/me with Bearer A1 A-->>C: 401 with error invalid_token C->>A: POST /auth/refresh with cookie R1 A->>D: Mark R1 used and save the hash of R2 in family F A-->>C: New access token A2 and R2 in the cookie Note over C,D: If R1 is ever presented again, the family F is revoked C->>A: POST /auth/logout A->>D: Revoke family F A-->>C: 204 and the cookie is cleared
// 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();
});

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

localStorage or sessionStorage

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 /auth/refresh at startup. Not reachable from other origins or by document.cookie.

Good for the short-lived access token.

HttpOnly cookie, Secure, SameSite

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;
  }
});

Everything the books call "just use the profile" has details that matter:

  • Identify by issuer and subject. The sub claim is stable and unique per provider; an email address can change, be reassigned, or be unverified. Link accounts through the verified email_verified claim 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 null and 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.protocol and req.hostname unless trust proxy is set correctly.

  • Request the least scope. Ask for openid email profile and 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 authenticate does in the user store above.

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, ...)

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.

Express 4 and Express 5 differences that touch authentication

  • Rejected promises reach the error handlers in Express 5. An async guard or login handler that throws needs no try/catch, and the JWT and Passport error paths end in the same handler. In Express 4, an async middleware that rejects hangs the request, so guards must catch and call next(err) (or use a wrapper).

  • req.body is no longer initialised to an empty object in Express 5. It is undefined when no parser ran or no body was sent, so write the fallback in the code and mount express.json() and express.urlencoded() before the authentication routes.

  • express.urlencoded defaults to extended: false. Form logins do not need the nested form of qs.

  • The magic back argument of res.redirect was removed in Express 5. Read the Referer header 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 proxy in both versions. req.ip and req.protocol feed callback URLs and rate-limit keys, so the setting must reflect the real proxy chain.

  • Route path syntax. Express 5 uses path-to-regexp 8; 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)

req.logout() takes a callback, req.login() regenerates the session, and keepSessionInfo is opt-in. 0.7 is the current line and its API is the one used here.

express-jwt 7 and 8

The decoded payload moved from req.user to req.auth, algorithms became mandatory, and the package ships its own types.

openid-client 6

Rewritten as functions on web-standard APIs (discovery, authorizationCodeGrant, buildAuthorizationUrl). Code written for the Issuer and Client classes of version 5 and earlier must be ported.

jose 5 and 6

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 qs and proxy-addr fixes that affect query and client-IP handling used by rate limiting. The authentication model is unchanged.

csurf deprecation

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 bcrypt-nodejs package, synchronously, at a low cost.

Both Monteiro books

bcrypt-nodejs is unmaintained. Use bcrypt (cost 12 or more) or argon2 with the asynchronous API, so the event loop is not blocked.

req.logout() without a callback, and logout through a GET link.

Sharma, both Monteiro books

Passport 0.6 and later requires req.logout(callback), regenerates the session, and expects POST plus CSRF protection.

Session fixation not considered at login.

Sharma, both Monteiro books

req.login() now regenerates the session id automatically.

express-session with resave: true, saveUninitialized: true, httpOnly: false, the default memory store, and no secure or sameSite.

Sharma, both Monteiro books

Use resave: false, saveUninitialized: false, an HttpOnly, Secure, SameSite cookie and a Redis or database store; set trust proxy behind a proxy.

JWTs with no expiry, a custom JWT scheme in the Authorization header, and storage in localStorage.

Sharma

Short exp, Authorization: Bearer, the token in memory, and a refresh token in an HttpOnly cookie with rotation.

No refresh and no revocation (acknowledged by the author).

Bugl

Refresh-token rotation with reuse detection, and a jti deny list or short lifetimes.

express-jwt configured without a pinned algorithm, and its 401 reaching the default HTML error page.

Bugl, chapter 6

algorithms is mandatory in express-jwt 8, the payload is on req.auth, and a JSON error handler translates UnauthorizedError.

A role or the author field copied from req.body (mass assignment) at registration or update.

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 null as the user to the callback.

Sharma, chapter 7

Create the local user on the first login, identify by issuer and subject, and use authorization code with PKCE and openid-client or a maintained Passport strategy.

Middleware registered before session(), so Passport finds no session.

Monteiro 2014, chapter 4

Mount session(), then passport.initialize(), then passport.session(), then the routes.

Session, JWT and OAuth secrets committed to the repository (including .env files in a companion repository).

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 express-rate-limit, schema validation (see Validation & Input Handling) and a maintained CSRF defence.

Common mistakes

Symptom Cause Fix

req.user is always undefined.

passport.session() is missing, mounted before session(), or serializeUser and deserializeUser are not defined.

Mount session(), passport.initialize() and passport.session() in that order, before the routes.

req.logout is not a function or Failed to serialize after an upgrade.

Old code calls req.logout() without a callback.

Pass a callback to req.logout that receives the error.

The cookie is set in development and missing in production.

secure: true behind a TLS-terminating proxy without trust proxy.

Set trust proxy to the real hop count and confirm req.secure.

Sessions are lost on restart or differ between instances.

The default MemoryStore.

Use a shared store such as Redis or a database.

Any token is accepted, or a forged token passes.

No algorithms list, or a public key used as an HMAC secret.

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 401.

401 for a user who is logged in but lacks a role.

The guard conflates authentication and authorization.

Return 401 when the identity is missing and 403 when it is present and not allowed.

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 authorization and cookie, and never put tokens in query strings.

A 500 on a duplicate email at sign-up.

The unique constraint error reaches the default handler.

Translate it to the neutral registration response or 409 for authenticated administrators.