Security Best Practices

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. Everything was checked against the official documentation, the package repositories and the npm registry on 2026-10-29. Express is deliberately small: it routes requests and runs middleware, and it makes almost no security decisions on the application’s behalf. Every protection in this page is therefore something the application adds, and most of them are one small, well-maintained middleware or one configuration line. The page follows the structure of the official production security page, adds the topics the official page only mentions (CSRF after the deprecation of csurf, secrets in Git, the threat model) and applies each one to the Bookshelf API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. The runtime-level concerns (process permissions, server timeouts, container hardening) are in the Node.js section, starting from Node.js Security Best Practices and Server Timeouts, Limits and Hardening; this page owns the framework layer.

What the official page asks for

The official page lists a short set of practices for production applications. The rest of this page expands each one, in this order:

Official recommendation What it means in practice Section

Do not use deprecated or vulnerable versions

Run a supported major (Express 5, or the latest 4.x) and apply every patch release; check the advisories.

Supported versions and security updates

Use TLS

Encrypt all traffic; terminate TLS in a proxy or in Node.js; send HSTS.

TLS and HTTPS

Do not trust user input

Validate and encode every input; prevent open redirects; avoid injection and path traversal.

Never trust user input

Use Helmet

Set the standard defensive response headers with one middleware.

Helmet

Reduce fingerprinting

Remove X-Powered-By; do not announce versions or stack traces.

Reducing fingerprinting

Use cookies securely

Custom cookie names, httpOnly, secure, sameSite, domain and path, expiry.

Cookies and sessions

Prevent brute-force attacks

Rate limit authentication endpoints and the API as a whole.

Preventing brute-force attacks

Ensure dependencies are secure

Audit, pin and update dependencies; keep the lockfile under review.

Dependencies

Avoid other known vulnerabilities

Watch the advisories of Express and of every dependency, including regular-expression denial of service.

Regular-expression denial of service and other known vulnerabilities, Supported versions and security updates

Additional considerations

Linters, process permissions, error output, secrets.

Error output and other defaults, Secrets, configuration and Git, Hardening checklist

Two protections the official page does not list as separate points are covered because they matter just as much in real applications: CSRF (CSRF protection after csurf) and the project’s own view of what is and is not an Express vulnerability (The Express threat model and reporting a vulnerability).

Supported versions and security updates

The first recommendation is the least glamorous one: run a version that still receives fixes. Express publishes security fixes as patch releases, and the fixes frequently live in a dependency of Express rather than in the framework’s own code, so the version of the transitive packages (body-parser, qs, cookie, send, serve-static, router, path-to-regexp) matters as much as the Express version itself.

Fact Value on 2026-10-29

Latest release

5.3.0, published 2026-10-09 (Node.js 18 or later). Its notes include qs fixes for security advisories, a proxy-addr update and req.fresh support for the QUERY method.

Latest Express 4

4.22.3 (the latest-4 npm dist-tag), published 2026-09-14. Express 4 runs on very old Node.js versions but you should still run a currently supported Node.js release.

Support policy

The support page lists v5.x and v4.x as ongoing; v3.x ended in July 2015. Read the support page before planning a long-lived Express 4 deployment, and see Versions, Support and LTS for the schedule and how the project communicates changes.

Where fixes are announced

The Express blog, the GitHub security advisories of the expressjs/express repository and the release notes (History.md). The security-updates page of the documentation lists the vulnerabilities fixed in the 4.x and 3.x lines.

Examples of the kind of issue that has been fixed in recent years, to show where the fixes come from rather than as a complete list: a regular-expression denial of service in path-to-regexp (fixed through Express 4 patch releases and by the rewrite in version 8 used by Express 5), a cross-site-scripting issue in res.redirect() output, prototype and denial-of-service issues in qs and body-parser, and characters accepted in cookie names by cookie. In each case the remedy was to update. Three habits make that cheap:

  • Pin Express to a range that accepts patch releases (^5.3.0) and commit the lockfile, so that npm ci installs exactly what was tested, while Dependabot or Renovate proposes the updates (Dependencies).

  • Subscribe to GitHub security advisories for the repositories you depend on (Watch, then Custom, then Security alerts), or enable the dependency graph and Dependabot alerts on your own repository.

  • Treat Express 4 as a migration backlog item rather than a destination: Express 5 changes several defaults in a more secure direction (a null-prototype req.params, req.query parsed with the simple parser, req.body undefined without an explicit parser, extended: false for express.urlencoded(), dotfiles: 'ignore' for express.static()) and removes the legacy forms of res.redirect() and res.send(). The migration is described in Migrating and What Changed.

TLS and HTTPS

Without TLS, the cookies, tokens and passwords of every request cross the network in the clear and can be read or modified by anyone on the path. The official page’s advice is to use TLS if the application handles or transmits sensitive data, and for an internet-facing API that means always. There are two deployment shapes.

TLS terminated at a proxy or load balancer (the common case). A reverse proxy such as Nginx, Caddy, Envoy or a cloud load balancer holds the certificate, speaks HTTPS to the world and forwards plain HTTP on a private network. Express then needs to be told to trust the proxy, otherwise req.protocol is always http, req.secure is false, secure cookies are not sent and req.ip is the proxy’s address (which also defeats rate limiting). The setting is trust proxy:

// tls-behind-proxy.mjs
import express from 'express';

const app = express();

// Exactly one proxy hop (a load balancer) sits in front of this process.
// Never use `true` on a server that is reachable directly from the internet:
// any client could then spoof X-Forwarded-For and X-Forwarded-Proto.
app.set('trust proxy', 1);

// Refuse plain HTTP: redirect, or answer 403 for API clients.
app.use((req, res, next) => {
  if (req.secure) {
    return next();
  }
  if (req.method === 'GET' || req.method === 'HEAD') {
    return res.redirect(308, `https://${req.hostname}${req.originalUrl}`);
  }
  res.status(403).json({ type: 'about:blank', title: 'HTTPS required', status: 403 });
});

app.get('/healthz', (req, res) => res.json({ status: 'ok' }));

app.listen(3000);

trust proxy accepts a hop count, a list of addresses or CIDR ranges, a named subnet (loopback, linklocal, uniquelocal) or a function; the full semantics, and why a wrong value lets clients forge their IP address, are in Application and Settings. Use the hostname from a fixed configuration value instead of req.hostname when the application can be reached under hostile Host headers, because a redirect built from a request header is itself an open-redirect risk (Open redirects).

TLS terminated by Node.js itself. This is appropriate for small deployments, internal services or end-to-end encryption inside a cluster. Express is a request listener, so it is handed to node:https instead of app.listen():

// tls-in-node.mjs
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import express from 'express';

const app = express();
app.get('/', (req, res) => res.json({ secure: req.secure }));

const server = createServer(
  {
    // Paths come from the environment, never from the repository.
    key: readFileSync(process.env.TLS_KEY_FILE),
    cert: readFileSync(process.env.TLS_CERT_FILE),
    minVersion: 'TLSv1.2',
  },
  app,
);

server.listen(8443, () => console.log('listening on https://localhost:8443'));

Notes on the Node.js side: minVersion: 'TLSv1.2' is already the default floor in current Node.js releases, but stating it documents the intent; certificates are read at start-up, so renewals need a reload (server.setSecureContext()) or a restart; and certificates should be obtained from an ACME certificate authority such as Let’s Encrypt, which the proxy or the platform usually automates. Test the result with an external scanner such as SSL Labs, and do not hand-craft cipher lists unless you have a specific compliance requirement: the defaults track current guidance.

HSTS. Once the site works over HTTPS, tell browsers never to use HTTP again with Strict-Transport-Security. Helmet sends it by default (max-age=31536000; includeSubDomains, one year) and Express does not add it by itself. Start with a short maxAge when first enabling it, raise it as confidence grows, and add preload only after reading the submission requirements, because preloading is difficult to undo. The header is only honoured over HTTPS, so it is harmless in development.

Never trust user input

Everything that arrives in a request is attacker-controlled: the path, the query string, headers (including Host, X-Forwarded-* and Origin), cookies, the body, uploaded file names and types. The official page singles out two concrete mistakes (open redirects and a handful of injection classes), and the general rule behind them is to validate on the way in, parameterise or encode on the way out and keep the allowed set small.

Validate at the boundary

Validation belongs in a middleware that runs before the handler and rejects with a 400 or 422 problem response. A schema library gives type coercion, unknown-key stripping and one place to read the rules; Zod, Joi, Ajv and express-validator are the usual choices and are compared in Validation and Input Handling. Three habits prevent most input-handling vulnerabilities:

  • Parse into a new object with an allow-list of fields. Spreading req.body into a database call is the cause of mass assignment: a client adds a role, an owner or a price field that the form never showed.

  • Reject unexpected types. A JSON body can hold an object where a string is expected, which becomes an operator object in a MongoDB query ({ $gt: '' }) or an array where a scalar was assumed.

  • Bound sizes: set a limit on express.json() and express.urlencoded(), a file size limit on uploads and a maximum page size on lists. Express 5 with body-parser 2 keeps the 100 KB default for JSON, and the parser rejects bodies over the limit with a 413, which is part of the denial-of-service defence (see Body Parsing).

The following middleware validates a new review for the Bookshelf API with an allow-list:

// validate-review.mjs
import { Router } from 'express';

export const router = Router();

const MAX_COMMENT = 2000;

// Builds a new object from known fields only; anything else in the body is dropped.
function parseReview(body) {
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
    return { error: 'Body must be a JSON object' };
  }
  const rating = Number(body.rating);
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
    return { error: 'rating must be an integer from 1 to 5' };
  }
  if (typeof body.comment !== 'string' || body.comment.length > MAX_COMMENT) {
    return { error: `comment must be a string of at most ${MAX_COMMENT} characters` };
  }
  return { value: { rating, comment: body.comment.trim() } };
}

router.post('/books/:bookId/reviews', (req, res) => {
  const { value, error } = parseReview(req.body);
  if (error) {
    return res.status(422).json({ title: 'Validation failed', detail: error, status: 422 });
  }
  // The author comes from the authenticated session, never from the body.
  const review = { ...value, bookId: req.params.bookId, authorId: req.user?.id };
  res.status(201).json(review);
});

Injection: SQL, NoSQL, commands and paths

Injection happens whenever data is concatenated into an interpreter’s input. The cures are the same in every case: pass data separately from the command, and where that is impossible, validate against an allow-list.

Interpreter Defence in an Express application

SQL

Parameterised queries or an ORM/query builder with bound parameters (pg, mysql2, Sequelize, Prisma, Drizzle, Kysely). Never build SQL with template strings from request values; identifiers such as column names for sorting come from an allow-list.

MongoDB

Reject object values where scalars are expected (schema validation, or Mongoose strict schemas and sanitizeFilter), never pass req.body or req.query straight to find(), and never enable server-side JavaScript. Express 5 parses the query string with the simple parser, so ?id[$gt]= no longer becomes an object by default, but a JSON body can still carry operators.

Operating system commands

Avoid child_process.exec() with request-derived strings. Use execFile() or spawn() with an argument array and no shell, and an allow-list of values.

File system paths

Never join a request value onto a directory without checking the result. res.sendFile() and res.download() accept a root option that rejects traversal; express.static() already refuses .. sequences and, in Express 5, dotfiles by default.

HTML and templates

Use a template engine with automatic escaping and learn its unescaped syntax (an unescaped tag in EJS or the != form in Pug are the usual leaks). Never concatenate user data into HTML in a handler.

Headers

Node.js rejects header values containing line breaks, which prevents response splitting; still validate values placed in Location, Content-Disposition and Set-Cookie.

Object keys (prototype pollution)

Never merge untrusted objects into configuration with a recursive merge function; req.params has a null prototype in Express 5, and qs guards against __proto__ keys, but your own merging code does not.

// safe-queries.mjs
import { basename } from 'node:path';
import { Router } from 'express';
import pg from 'pg';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const router = Router();

const SORTABLE = new Map([
  ['title', 'title'],
  ['published', 'published_at'],
]);

router.get('/books', async (req, res) => {
  const { author, sort = 'title' } = req.query;

  // The sort column is chosen from an allow-list, never interpolated from input.
  const column = SORTABLE.get(String(sort));
  if (!column) {
    return res.status(400).json({ title: 'Unknown sort field', status: 400 });
  }

  // The value travels as a bound parameter ($1), never inside the SQL text.
  const { rows } = await pool.query(
    `SELECT id, title FROM books WHERE ($1::text IS NULL OR author = $1) ORDER BY ${column}`,
    [typeof author === 'string' ? author : null],
  );
  res.json(rows);
});

router.get('/covers/:file', (req, res) => {
  // basename() strips directories; root makes sendFile refuse anything outside it.
  res.sendFile(basename(req.params.file), { root: 'covers', dotfiles: 'deny' });
});

Open redirects

The official page warns against redirecting to a location taken from the request, because an attacker can send victims a link on your trusted domain that bounces them to a phishing site. This is the classic login flow bug: GET /login?next=https://evil.example followed by res.redirect(req.query.next). Express 5 removed the res.redirect('back') form (which trusted the Referer header), but nothing removes your own uses of a request value. Resolve the target against your own origin and compare:

// safe-redirect.mjs
import { Router } from 'express';

export const router = Router();

const ORIGIN = new URL(process.env.PUBLIC_ORIGIN ?? 'https://bookshelf.example');

// Returns a same-origin path or the fallback; never an absolute URL to another site.
export function safeRedirectTarget(candidate, fallback = '/') {
  if (typeof candidate !== 'string') {
    return fallback;
  }
  try {
    const url = new URL(candidate, ORIGIN);
    if (url.origin !== ORIGIN.origin) {
      return fallback;
    }
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return fallback;
  }
}

router.post('/login/complete', (req, res) => {
  res.redirect(303, safeRedirectTarget(req.body?.next));
});

Resolving with new URL() handles the tricks a naive startsWith('/') check misses (//evil.example, /\evil.example, https:evil.example, embedded tabs). For a fixed set of destinations, an allow-list of named keys is even simpler than validating a URL. Use the status 303 after a POST so that the browser switches to GET, and 307 or 308 only when the method must be preserved.

Helmet

Helmet is a collection of small middleware functions that set response headers browsers use to turn on defensive behaviour. It is the official page’s most prominent recommendation because it is one line, it is maintained by the Express ecosystem, and it fixes a long list of "default insecure" browser behaviours. Version 8.3.0 requires Node.js 18 or later and is compatible with Express 4 and 5.

// helmet-basic.mjs
import express from 'express';
import helmet from 'helmet';

const app = express();

// Register it first, so that even error and 404 responses carry the headers.
app.use(helmet());

app.get('/', (req, res) => res.json({ ok: true }));
app.listen(3000);

Every header Helmet sets

helmet() is a bundle; each header has its own middleware under helmet.<name>() and a matching key to configure or disable it in the options object (false disables, an object configures).

Header (option key) Default value What it protects against

Content-Security-Policy (contentSecurityPolicy)

default-src 'self', base-uri 'self', font-src 'self' https: data:, form-action 'self', frame-ancestors 'self', img-src 'self' data:, object-src 'none', script-src 'self', script-src-attr 'none', style-src 'self' https: 'unsafe-inline', upgrade-insecure-requests

Cross-site scripting and data injection, by restricting where scripts, styles, images and frames may come from. The main header to tailor.

Cross-Origin-Opener-Policy (crossOriginOpenerPolicy)

same-origin

Process-isolates the page from cross-origin windows it opens or that open it (some Spectre-style and window-handle attacks).

Cross-Origin-Resource-Policy (crossOriginResourcePolicy)

same-origin

Stops other origins from embedding your resources. Change to cross-origin for public images or an API consumed by other sites' scripts.

Cross-Origin-Embedder-Policy (crossOriginEmbedderPolicy)

Not set (opt in with true, giving require-corp)

Required together with COOP for cross-origin isolation features; leave it off unless you need them.

Origin-Agent-Cluster (originAgentCluster)

?1

Asks the browser to isolate the origin in its own agent cluster.

Referrer-Policy (referrerPolicy)

no-referrer

Controls how much of the URL is leaked in the Referer header; relax to strict-origin-when-cross-origin if analytics or partners need the origin.

Strict-Transport-Security (strictTransportSecurity)

max-age=31536000; includeSubDomains

Forces HTTPS for a year (TLS and HTTPS). Options: maxAge, includeSubDomains, preload.

X-Content-Type-Options (xContentTypeOptions)

nosniff

Stops browsers from guessing a MIME type different from the declared one, which turns an uploaded text file into script.

X-DNS-Prefetch-Control (xDnsPrefetchControl)

off

Disables speculative DNS lookups that can leak browsing intent.

X-Download-Options (xDownloadOptions)

noopen

Legacy Internet Explorer protection against opening downloads in the site’s context.

X-Frame-Options (xFrameOptions)

SAMEORIGIN

Clickjacking protection (a legacy complement to CSP frame-ancestors). Option action: DENY or SAMEORIGIN.

X-Permitted-Cross-Domain-Policies (xPermittedCrossDomainPolicies)

none

Tells Adobe Flash and Acrobat clients not to load cross-domain policy files.

X-Powered-By (xPoweredBy)

Header removed

Removes X-Powered-By: Express; see Reducing fingerprinting.

X-XSS-Protection (xXssProtection)

0

Deliberately disables the legacy, buggy browser XSS auditor, whose behaviour could itself create vulnerabilities. Rely on CSP instead.

Helmet does not set Permissions-Policy, Cache-Control or Clear-Site-Data; add them yourself where needed (Cache-Control: no-store on responses that contain personal data, for example).

Tailoring Content-Security-Policy

The default policy is strict on purpose and breaks inline scripts, third-party script hosts and cross-origin images until they are allowed. Two different situations call for different configurations.

A JSON API returns no HTML for browsers to render, so the policy is mostly irrelevant, but leaving the defaults on is harmless and protects the error pages and any documentation page the API serves. Do not weaken it for an API.

A server-rendered application or a single-page application served by Express needs a policy that matches its assets. Prefer nonces or hashes over 'unsafe-inline' for scripts: generate a random nonce for each response, expose it to the template, and reference it from the policy with a function in the directive array.

// helmet-csp.mjs
import { randomBytes } from 'node:crypto';
import express from 'express';
import helmet from 'helmet';

const app = express();

// A fresh, unguessable nonce for every response.
app.use((req, res, next) => {
  res.locals.cspNonce = randomBytes(16).toString('base64');
  next();
});

app.use(
  helmet({
    contentSecurityPolicy: {
      // Start from Helmet's defaults and change only what the app needs.
      useDefaults: true,
      directives: {
        'script-src': ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
        'img-src': ["'self'", 'data:', 'https://covers.example.com'],
        'connect-src': ["'self'", 'https://api.bookshelf.example'],
        'frame-ancestors': ["'none'"],
        // Send violation reports to your own collector.
        'report-uri': ['/csp-report'],
      },
      // Observe first, enforce later: set to true to use Content-Security-Policy-Report-Only.
      reportOnly: false,
    },
    // Public cover images are fetched by other sites, so relax this one header.
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true },
  }),
);

// Browsers post reports as application/csp-report or application/reports+json.
app.post(
  '/csp-report',
  express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'], limit: '10kb' }),
  (req, res) => {
    console.warn('CSP violation', JSON.stringify(req.body).slice(0, 2000));
    res.sendStatus(204);
  },
);

app.get('/', (req, res) => {
  res.type('html').send(`<!doctype html>
<title>Bookshelf</title>
<script nonce="${res.locals.cspNonce}">window.bookshelf = { ready: true };</script>`);
});

app.listen(3000);

Practical advice for CSP in production: ship in report-only mode first and read the reports, keep object-src 'none' and base-uri 'self', avoid wildcard hosts, never put user data into a policy, and remember that Helmet’s default upgrade-insecure-requests makes Safari rewrite http://localhost to HTTPS during development (set it to null for local runs). Individual middleware can also be mounted per route, for example a looser policy for an embeddable widget route only.

Reducing fingerprinting

By default Express adds X-Powered-By: Express to every response. This does not create a vulnerability, but it tells scanners which exploit database to consult. Helmet removes it, and Express can do it without Helmet:

// fingerprint.mjs
import express from 'express';

const app = express();
app.disable('x-powered-by'); // same as app.set('x-powered-by', false)

Removing a header is a speed bump for opportunistic bots, not a defence, and the official page says so; the real protection is patching. Also avoid announcing details through other channels: do not expose the stack trace in error responses (Error output and other defaults), do not return framework-specific default error pages from an API, keep /metrics, /debug and documentation pages off the public interface, and make 404 and 405 responses the same shape for all unknown paths. Be consistent with authentication errors too: a login endpoint that answers differently for an unknown user and a wrong password enables user enumeration (see Authentication and Authorization).

Cookies and sessions

The official page gives two rules: do not use the default session cookie name, and set the cookie security options. The complete treatment, including stores, rotation and regeneration at login, is in Cookies and Sessions; the security-relevant summary is:

Option Why it matters

name

The default connect.sid reveals the session middleware. Use a neutral name. Prefix with __Host- in production (requires secure, path: '/' and no domain), which makes the cookie impossible to overwrite from a sibling subdomain.

httpOnly

Hides the cookie from document.cookie, so an XSS bug cannot read the session identifier.

secure

Sends the cookie only over HTTPS. It requires a correct trust proxy setting behind a TLS-terminating proxy, otherwise the cookie is silently never set.

sameSite

lax is the safe default; strict for the most sensitive cookies; none (requires secure) only for deliberate cross-site use. It is the first line of defence against CSRF (CSRF protection after csurf).

maxAge or expires

Keep lifetimes short and use rolling only where an idle timeout is wanted.

domain and path

Leave domain unset so the cookie is host-only; scope path to what needs it.

Store

The default memory store leaks memory and loses sessions at restart. Use a production store (Redis, a database) and set resave: false and saveUninitialized: false.

Secret

Load it from the environment, make it long and random, and use an array of secrets to rotate without logging everyone out.

Also regenerate the session identifier at login with req.session.regenerate() to prevent session fixation, and destroy the session on logout.

Preventing brute-force attacks

Login forms, password reset, one-time-code verification, search and any expensive endpoint need a limit on how often one client may call them. Without it, credential stuffing, enumeration and resource exhaustion are free. The official page names two packages, and both are current:

  • express-rate-limit (8.7.2 on 2026-10-29): a middleware with a fixed-window default and pluggable stores, the right tool for "N requests per window per key" on routes.

  • rate-limiter-flexible (11.2.3): a library of limiters (memory, Redis, MongoDB, SQL, cluster), with consecutive-failure limits, penalty and block durations; the right tool when the rule is about failed logins per user and address rather than raw request volume.

express-rate-limit

// rate-limit.mjs
import express from 'express';
import { ipKeyGenerator, rateLimit } from 'express-rate-limit';

const app = express();

// The limiter keys on req.ip. Behind a proxy that is the proxy's address
// unless trust proxy is set, which would put every user in one bucket.
app.set('trust proxy', 1);

// A general ceiling for the whole API.
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 300, // per key and window ("limit" replaced the older "max" name)
  standardHeaders: 'draft-8', // RateLimit and RateLimit-Policy headers
  legacyHeaders: false, // no X-RateLimit-* headers
  ipv6Subnet: 56, // count a whole /56 as one client for IPv6
  handler: (req, res, next, options) => {
    res.status(options.statusCode).json({ title: 'Too many requests', status: options.statusCode });
  },
});

// Two much stricter limiters on the login route. Only failed attempts count in either.
// Per address: one client trying many accounts. ipKeyGenerator() applies the IPv6 subnet grouping.
const loginLimiterByIp = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  skipSuccessfulRequests: true,
  keyGenerator: (req) => ipKeyGenerator(req.ip),
  standardHeaders: 'draft-8',
  legacyHeaders: false,
});

// Per account: many addresses (a botnet) trying one account. Looser and longer, because an
// attacker can use it to lock the real user out.
const loginLimiterByAccount = rateLimit({
  windowMs: 60 * 60 * 1000,
  limit: 20,
  skipSuccessfulRequests: true,
  keyGenerator: (req) => `account:${String(req.body?.email ?? '').trim().toLowerCase()}`,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
});

app.use(express.json({ limit: '100kb' }));
app.use('/api', apiLimiter);
app.post('/api/v1/login', loginLimiterByIp, loginLimiterByAccount, (req, res) => {
  // authenticate here; a failed attempt consumes quota, a success is not counted
  res.sendStatus(401);
});

app.listen(3000);

Points that matter in production:

  • The key. The default key is the client IP. IPv6 clients can rotate addresses inside a /64, so the library groups addresses by subnet (ipv6Subnet) and exposes ipKeyGenerator() for custom keys that include the address, as above; a custom key built from the raw req.ip fails the library’s IPv6 validation. On login, use two limiters rather than one combined ip:email key: the address limiter slows one client trying many accounts, and the account limiter (keyed by the normalized email) slows a botnet spread over many addresses attacking one account, which a combined key never does because every address gets its own budget. Keep the account limit looser, since anyone can spend it to lock the real user out.

  • The store. The default store is in the memory of one process. With several processes or containers every instance counts separately, so the effective limit is multiplied and a restart resets it. Use a shared store such as rate-limit-redis (6.0.2) or one of the other store packages for anything that runs in more than one process.

  • The proxy. An incorrect trust proxy either puts everyone in one bucket (not set) or lets clients choose their own key by forging X-Forwarded-For (set too permissively). The library validates the configuration and logs a warning at start-up; read it.

  • Status and headers. Answer 429 with a Retry-After header (the library sets it) so that well-behaved clients back off, and use the standard RateLimit headers rather than the legacy X-RateLimit-* ones.

  • Layering. Rate limit at the edge (CDN, gateway, Nginx limit_req) for volumetric traffic, and in the application for the rules that need business context (per account, per API key). Neither replaces the other.

// rate-limit-redis.mjs
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();

export const sharedLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => client.sendCommand(args),
    prefix: 'rl:bookshelf:',
  }),
});

rate-limiter-flexible for login protection

A middleware that counts requests treats a legitimate user and an attacker the same. Account-level protection counts consecutive failures for the pair of username and address, and for the username alone, then blocks for an increasing time. rate-limiter-flexible is built for that:

// login-bruteforce.mjs
import { Router } from 'express';
import { RateLimiterMemory } from 'rate-limiter-flexible';

// Swap RateLimiterMemory for RateLimiterRedis (same API) when running several processes.
const limiterByIpAndUser = new RateLimiterMemory({
  keyPrefix: 'login_ip_user',
  points: 5, // five failures...
  duration: 15 * 60, // ...per 15 minutes
  blockDuration: 15 * 60, // then blocked for 15 minutes
});

const limiterByUser = new RateLimiterMemory({
  keyPrefix: 'login_user',
  points: 20, // a slower limit that survives IP rotation
  duration: 60 * 60,
  blockDuration: 60 * 60,
});

export const loginRouter = Router();

loginRouter.post('/login', async (req, res) => {
  const email = String(req.body?.email ?? '').toLowerCase();
  const ipAndUser = `${req.ip}:${email}`;

  const [byIpAndUser, byUser] = await Promise.all([
    limiterByIpAndUser.get(ipAndUser),
    limiterByUser.get(email),
  ]);
  const retryMs = Math.max(
    byIpAndUser && byIpAndUser.remainingPoints <= 0 ? byIpAndUser.msBeforeNext : 0,
    byUser && byUser.remainingPoints <= 0 ? byUser.msBeforeNext : 0,
  );
  if (retryMs > 0) {
    res.set('Retry-After', String(Math.ceil(retryMs / 1000)));
    return res.status(429).json({ title: 'Too many failed attempts', status: 429 });
  }

  const ok = await verifyPassword(email, req.body?.password); // your authentication
  if (!ok) {
    await Promise.all([limiterByIpAndUser.consume(ipAndUser).catch(() => {}), limiterByUser.consume(email).catch(() => {})]);
    return res.status(401).json({ title: 'Invalid credentials', status: 401 });
  }

  await limiterByIpAndUser.delete(ipAndUser); // a success clears the address-level counter
  res.json({ ok: true });
});

async function verifyPassword(email, password) {
  return false; // placeholder: look up the user and compare a password hash
}

The same libraries support limiting by API key, by authenticated user id, and by cost (a request that triggers an expensive job consumes more points). Whatever the tool, return the same response and similar timing for unknown users and wrong passwords, otherwise the limiter protects the password but not the list of accounts. Pair limits with multi-factor authentication where the data warrants it.

CSRF protection after csurf

Cross-site request forgery makes a victim’s browser send a state-changing request to your site, with the victim’s cookies attached automatically, from a page the attacker controls. It only matters when authentication is carried by something the browser attaches by itself: cookies (sessions) and HTTP authentication. An API that authenticates with an Authorization: Bearer header supplied by script code is not vulnerable to classic CSRF, because a forged cross-site form cannot add that header.

csurf is deprecated

The csurf middleware (npm 1.11.0) was deprecated by the Express team in 2025 (announcement on the Express blog on 2025-05-16, together with connect-multiparty and path-match) because of unresolved design problems and no active maintainer. The npm package is marked deprecated, the middleware page is still listed, and it must not be used in new code. Tutorials and all four reference books that show csrf() or csurf are therefore outdated here (see Where the books are outdated). There is no drop-in replacement from the Express project; the choice depends on how the application authenticates.

Application shape Recommended defence Tooling

Cookie-session application (server-rendered pages or an SPA using express-session)

SameSite cookies plus a synchroniser token stored in the session

csrf-sync (4.2.1) with express-session

Stateless or cross-site front end that cannot keep server session state

SameSite cookies plus a double-submit cookie token

csrf-csrf (4.0.3) with cookie-parser

Any cookie-authenticated app that targets modern browsers

Verify Origin and Sec-Fetch-Site on unsafe methods as an additional layer

A few lines of your own middleware (below), no dependency

API authenticated by bearer tokens in a header (not cookies)

No CSRF token needed; protect against XSS instead (CSP, output encoding) and keep tokens out of localStorage

Helmet, CORS allow-list

Always start from the cheapest layer: set sameSite: 'lax' (or strict) on the session cookie, make sure that no state-changing action is reachable by GET, and restrict CORS to known origins. SameSite blocks the common cross-site form post, but it does not protect against same-site attackers (a vulnerable sibling subdomain), and it leaves old browsers unprotected, which is why a token is still the standard defence for session cookies.

Synchroniser token with csrf-sync

csrf-sync stores a random token in the server-side session and compares it with the token the client sends back in a header or form field. It needs express-session registered first.

// csrf-sync.mjs
import express from 'express';
import session from 'express-session';
import { csrfSync } from 'csrf-sync';

const app = express();
app.set('trust proxy', 1);

app.use(express.json());
app.use(
  session({
    name: '__Host-bookshelf.sid',
    secret: process.env.SESSION_SECRET, // loaded from the environment, never committed
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' },
  }),
);

const { generateToken, csrfSynchronisedProtection } = csrfSync({
  // Default is the x-csrf-token header; make that explicit.
  getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});

// Safe method: hands the token to the front end (or put it in a rendered form instead).
app.get('/csrf-token', (req, res) => {
  res.json({ token: generateToken(req) });
});

// Everything registered after this line is protected for POST, PUT, PATCH and DELETE.
app.use(csrfSynchronisedProtection);

app.post('/api/v1/books/:id/reviews', (req, res) => {
  res.status(201).json({ created: true });
});

// A failed check raises a 403 error; render it like any other.
app.use((err, req, res, next) => {
  if (err.code === 'EBADCSRFTOKEN' || err.statusCode === 403) {
    return res.status(403).json({ title: 'Invalid CSRF token', status: 403 });
  }
  next(err);
});

app.listen(3000);

On the client, fetch the token once after login, keep it in memory (not in localStorage), and send it in the x-csrf-token header of every non-safe request. Regenerate the session, and with it the token, at login and logout.

Double-submit cookie with csrf-csrf

csrf-csrf implements the signed double-submit cookie pattern, which needs no server-side storage but binds the token to an identifier you supply. In version 4 you must provide getSessionIdentifier (a value unique to the user’s session, such as the session id) and a getSecret that returns a secret from the environment. Register cookie-parser before the protection (and after express-session if both are used).

// csrf-csrf.mjs
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
import express from 'express';
import session from 'express-session';

const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(cookieParser());

const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  getSessionIdentifier: (req) => req.session.id,
  cookieName: '__Host-bookshelf.x-csrf-token',
  cookieOptions: { sameSite: 'strict', path: '/', secure: true, httpOnly: true },
  getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'], // header only, no fallthrough chain
});

app.get('/csrf-token', (req, res) => {
  res.json({ token: generateCsrfToken(req, res) });
});

app.use(doubleCsrfProtection);

app.delete('/api/v1/books/:id', (req, res) => res.sendStatus(204));

app.listen(3000);

Points that apply to both libraries: read the token from exactly one place (a header or a body field) rather than a chain of fallbacks, which was one of the weaknesses of csurf; keep GET, HEAD and OPTIONS side-effect free; transmit tokens only over HTTPS; and when the front end is on a different origin, configure CORS with credentials: true and an exact origin, plus withCredentials on the client. See CORS and the theory page CORS.

A dependency-free extra layer: Origin and Fetch Metadata

Modern browsers send Sec-Fetch-Site on every request and Origin on unsafe methods. A short middleware can reject cross-site state-changing requests outright. It complements a token rather than replacing it, since non-browser clients and old browsers send neither header.

// fetch-metadata.mjs
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const ALLOWED_ORIGINS = new Set([process.env.PUBLIC_ORIGIN ?? 'https://bookshelf.example']);

export function rejectCrossSite(req, res, next) {
  if (!UNSAFE.has(req.method)) {
    return next();
  }
  const site = req.get('sec-fetch-site');
  if (site && site !== 'same-origin' && site !== 'none') {
    return res.status(403).json({ title: 'Cross-site request rejected', status: 403 });
  }
  const origin = req.get('origin');
  if (origin && !ALLOWED_ORIGINS.has(origin)) {
    return res.status(403).json({ title: 'Origin not allowed', status: 403 });
  }
  next();
}

Dependencies

A typical Express application ships several hundred transitive packages, so most vulnerabilities that affect it are found in code nobody on the team wrote. The official page asks for three practices:

  • Audit. npm audit compares the installed tree with the advisory database; npm audit --omit=dev limits the report to what runs in production, and npm audit signatures verifies registry signatures and provenance attestations. Fail the build on high and critical findings, and review each report rather than running npm audit fix --force blindly, since a major bump can change behaviour. The older third-party tools the official page lists (nsp, retire.js) have been superseded: nsp is gone and its data lives in the npm audit database, while Snyk, GitHub Dependabot alerts, OSV-Scanner and Socket cover the same ground with more context.

  • Pin and reproduce. Commit package-lock.json, install with npm ci in CI and images (it fails when the lockfile and package.json disagree), and review lockfile diffs in pull requests, because that is where a malicious or typosquatted package arrives.

  • Update continuously. Small, frequent updates are far safer than rare large ones. Configure Dependabot or Renovate to open grouped pull requests, and merge security updates quickly.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    groups:
      minor-and-patch:
        update-types: [minor, patch]

Reduce the attack surface at the same time: prefer fewer, well-maintained dependencies; check that a package is maintained, has multiple maintainers and supports Express 5 before adopting it; remove unused packages; use npm install --ignore-scripts (or ignore-scripts=true in .npmrc) in CI where install scripts are not needed; enable two-factor authentication for your own npm account; and, if the application is a library or an image, produce an SBOM. The deprecated middleware (csurf, connect-multiparty, path-match) should be removed rather than kept: unmaintained code receives no fixes.

Regular-expression denial of service and other known vulnerabilities

A regular expression with nested or overlapping quantifiers (for example a group repeated with a plus sign that itself contains a repeated group) can take exponential time on an adversarial string, and because Node.js runs JavaScript on one thread, one slow match stalls every request. The official page lists this under known vulnerabilities, with these practical countermeasures:

  • Do not run user-supplied patterns. If a feature must allow search patterns, use a linear-time engine such as RE2 (the re2 package, 1.27.0 on 2026-10-29), or escape the input and do a substring search.

  • Test your own patterns with a checker such as safe-regex2 (5.1.2) in CI, or the ESLint rule from eslint-plugin-security (detect-unsafe-regex), and keep expressions simple and anchored.

  • Prefer parsing to pattern matching: new URL() for URLs, a validator library for email addresses, split-and-check for fixed formats.

  • Bound the input before matching: reject strings above a sane length (and limit the body size) first.

  • Mind the route layer. Express 4 compiled route strings with path-to-regexp 0.1.x, which had several ReDoS advisories; Express 5 uses path-to-regexp 8 (through router 2), which removes inline regular expressions in string paths and so removes that attack class. Do not re-introduce it with RegExp routes built from input.

// redos-guard.mjs
import RE2 from 're2';

const MAX_LENGTH = 200;

// RE2 matches in linear time and rejects constructs that cause backtracking.
export function safeSearch(req, res) {
  const raw = String(req.query.q ?? '');
  if (raw.length > MAX_LENGTH) {
    return res.status(400).json({ title: 'Query too long', status: 400 });
  }
  let matcher;
  try {
    matcher = new RE2(raw, 'i');
  } catch {
    return res.status(400).json({ title: 'Invalid pattern', status: 400 });
  }
  const books = ['Dune', 'Emma', 'Ulysses'].filter((title) => matcher.test(title));
  res.json(books);
}

Other known issue classes the official page tells readers to keep an eye on: prototype pollution through merging untrusted objects, unsafe deserialisation, eval and new Function() on request data, and server-side request forgery when the server fetches a URL supplied by the client (resolve, then allow-list host and address ranges, and disable redirects). A static analyser (CodeQL, Semgrep, eslint-plugin-security) catches many of these during review. The official page also recommends checking sites such as Node Security and the Snyk vulnerability database, which today means the GitHub advisory database and the npm audit feed.

Error output and other defaults

Errors are an information channel for attackers. In Express the default error handler prints the stack trace unless NODE_ENV is production, so running production with the variable unset leaks file paths, versions and internals; the setting also turns on view caching and other optimisations (see Performance Best Practices). Add your own error handler that logs the full error on the server and returns a generic body for 5xx responses, as described in Error Handling. Do not install the errorhandler development middleware in production.

Further defaults to review before going live:

  • Set request, header and keep-alive timeouts on the http.Server and limit body sizes (see Server Timeouts, Limits and Hardening); Express itself sets none.

  • Do not serve dotfiles or source directories: Express 5 ignores dotfiles in express.static() by default (which also hides /.well-known, so mount that folder explicitly when you need it).

  • Run the process as an unprivileged user, in a container with a read-only file system where possible, and never as root; on Node.js you can also use the permission model for additional limits.

  • Log security events (failed logins, authorisation failures, rate limit hits) with a request identifier, never with passwords, tokens or full card numbers, and keep logs access-controlled.

  • Respond to upload endpoints with limits on size, count and type, store files outside the web root with generated names, and never trust the client-supplied MIME type or originalname (see File Uploads and Downloads).

The Express threat model and reporting a vulnerability

The Express project, part of the OpenJS Foundation, has a technical committee, a security triage team, a published security policy and a threat model for the framework. The practical consequences for an application developer are:

  • What Express takes responsibility for. Parsing the request line and headers (through Node.js), routing, the behaviour of its own helpers (res.redirect(), res.cookie(), res.sendFile(), req.ip) and the bundled body parsers and static file server. A bug that lets crafted input make one of these do something its documentation says it must not do is in scope: for example sending a response to a different route, escaping a static root, executing injected markup from a helper’s own output, or exhausting resources with a small input.

  • What belongs to the application. Authentication, authorisation, input validation, output encoding in your own templates, session store choice, TLS, secrets, rate limiting and the choice of dependencies. A deployment that is vulnerable because trust proxy is set to true, a RegExp route was built from user input, or a handler concatenates input into SQL is not an Express vulnerability, and the project will correctly close such reports.

  • The environment. Express assumes the Node.js runtime and the operating system are patched and that the code it runs is trusted: a malicious dependency or a compromised build is outside what a web framework can defend against.

flowchart LR C["Client or attacker"] -->|"requests"| E["Edge (operator): TLS, WAF, rate limit"] E --> X["Express project: routing, parsers, helpers"] X --> A["Application owner: validation, authn and authz"] A --> D["Database, queues, other services"] P["Dependencies: npm packages"] -.->|"run inside"| X P -.->|"run inside"| A

To report a suspected vulnerability in Express itself, do not open a public issue. Follow the repository’s security policy (the Security tab of expressjs/express and the contributing page of the documentation), which uses GitHub’s private vulnerability reporting, and give a minimal reproduction, the affected versions and your assessment of impact. Reports about a dependency go to that dependency’s own maintainers; the same applies to your own users, who should have a SECURITY.md and a security contact in your repository.

Secrets, configuration and Git

Session secrets, JWT signing keys, OAuth client secrets, database passwords and API keys must never be in the repository, in an image layer, in a log line or in an error message. Committed secrets are the most common real-world failure, because a secret in Git history is public the moment the repository is, and deleting it in a later commit does nothing for the history. The rules:

  1. Configuration comes from the environment. Read secrets from process.env at start-up and fail fast when a required value is missing. In development, keep them in an untracked .env file, loaded natively on Node.js 20.6 and later with node --env-file=.env app.mjs; in production, inject them from the platform’s secret manager (container orchestrator secrets, a cloud secret manager, Vault) rather than baking a file into the image. The configuration pattern is in Project Structure and Configuration.

  2. Commit an example, never the real file. A .env.example with placeholder values documents which variables exist.

  3. Ignore the real file. .gitignore and .dockerignore list .env, .env.* (except the example), key and certificate files, and local database dumps.

  4. Scan before it is committed. Run a secret scanner as a pre-commit hook and again in CI, such as detect-secrets, gitleaks or trufflehog, and enable GitHub secret scanning with push protection on the repository.

  5. Rotate on exposure. If a secret has ever been pushed, treat it as compromised: revoke and replace it first, then (if you must) rewrite history with git filter-repo and force-push; history rewriting does not recall copies already cloned or cached.

  6. Separate environments. Use different secrets for development, staging and production, scope database users to the least privilege needed, and never reuse a production secret locally.

// config.mjs
// Fails fast at start-up instead of running with a missing or default secret.
function required(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable ${name}`);
  }
  return value;
}

export const config = Object.freeze({
  env: process.env.NODE_ENV ?? 'development',
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: required('DATABASE_URL'),
  sessionSecret: required('SESSION_SECRET'),
  csrfSecret: required('CSRF_SECRET'),
});
# .env.example  (committed; placeholder values only)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://bookshelf:change-me@localhost:5432/bookshelf
SESSION_SECRET=replace-with-a-long-random-value
CSRF_SECRET=replace-with-another-long-random-value

# .gitignore  (excerpt)
.env
.env.*
!.env.example
*.pem
*.key

# Generate a strong random value on your machine, then store it in the secret manager:
#   node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
#
# Run once per repository, then on every commit:
#   detect-secrets scan > .secrets.baseline
#   pre-commit install

A secret scanner will sometimes flag placeholders such as the ones above; keep a baseline file of audited false positives so that the scan only reports new findings. Never "fix" a finding by editing the baseline when it is a real secret: rotate it.

A hardened Bookshelf application

The following module combines the practices above into the start-up code of the API. It is intentionally short because each concern is one middleware; the order matters (headers first, then parsing and limits, then routes, then the error handler).

// app.mjs
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import helmet from 'helmet';
import { config } from './config.mjs';
import { rejectCrossSite } from './fetch-metadata.mjs';

export const app = express();

app.disable('x-powered-by');
app.set('trust proxy', 1);

app.use(helmet());
app.use(
  cors({
    origin: ['https://bookshelf.example'], // an exact allow-list, never a wildcard with credentials
    credentials: true,
    maxAge: 600,
  }),
);
app.use(rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-8', legacyHeaders: false }));
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '100kb' }));
app.use(cookieParser(config.sessionSecret));
app.use(rejectCrossSite);

app.get('/healthz', (req, res) => res.json({ status: 'ok' }));

// ... routers: books, reviews, authors, auth ...

app.use((req, res) => res.status(404).json({ title: 'Not found', status: 404 }));

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  const status = err.status ?? err.statusCode ?? 500;
  console.error(err); // full detail stays on the server
  res.status(status).json({
    title: status >= 500 ? 'Internal Server Error' : err.message,
    status,
  });
});

The app/server split described in Project Structure and Configuration keeps app.listen() out of this module, so tests can import it and assert the security behaviour: the headers on a 404, the 429 after the limit, the 403 without a CSRF token and the absence of x-powered-by.

Hardening checklist

Check How Section

Supported Express and Node.js, patched weekly

Dependabot or Renovate, npm audit in CI, advisory subscriptions

Supported versions and security updates, Dependencies

TLS everywhere, HSTS on

Proxy or node:https, helmet(), trust proxy set to the real hop count

TLS and HTTPS

Security headers

helmet() first, CSP tailored with nonces, report-only before enforcing

Helmet

x-powered-by removed, generic errors, NODE_ENV=production

app.disable, own error handler

Reducing fingerprinting, Error output and other defaults

Input validated against schemas, bodies limited

Zod, Joi, Ajv or express-validator; limit options

Never trust user input

No injection, traversal or open redirect

Bound parameters, root option, same-origin redirect helper

Injection: SQL, NoSQL, commands and paths, Open redirects

Cookies hardened, session regenerated at login

__Host- name, httpOnly, secure, sameSite, shared store

Cookies and sessions

CSRF handled for cookie authentication

SameSite, csrf-sync or csrf-csrf, Origin and Fetch Metadata

CSRF protection after csurf

Brute force and abuse limited

express-rate-limit per route and API, rate-limiter-flexible for login failures, shared store

Preventing brute-force attacks

CORS restricted

Exact origin allow-list, credentials only when needed

A hardened Bookshelf application

No secrets in Git

.env.example, ignore files, pre-commit and CI scanner, push protection, rotation procedure

Secrets, configuration and Git

ReDoS and unsafe patterns avoided

RE2, safe-regex2, bounded input, Express 5 paths

Regular-expression denial of service and other known vulnerabilities

Vulnerability reporting path

SECURITY.md, private reporting

The Express threat model and reporting a vulnerability

Where the books are outdated

The four reference books were written for Express 4.0 to 4.18 and Node.js 6 to 20, and none of them covers application security as a topic of its own. Used as a starting point for concepts, they need the following corrections (described here, never reproduced):

Weakness in the book examples Current practice

Session secrets, JWT secrets, OAuth credentials and database passwords written into source files, and .env files committed to the companion repositories

Environment variables or a secret manager, .env.example, ignore rules, secret scanning and rotation (Secrets, configuration and Git).

express-session with resave: true, saveUninitialized: true, httpOnly off, the default memory store, no secure or sameSite, and no trust proxy behind a platform proxy

Secure options, a production store, a neutral cookie name and the right proxy setting (Cookies and sessions, TLS and HTTPS).

No Helmet in any book

One middleware sets the standard headers (Helmet).

No rate limiting or lockout on login routes

Per-route and per-account limits with a shared store (Preventing brute-force attacks).

No CSRF protection, or csurf where it is mentioned

csurf is deprecated; use SameSite cookies with csrf-sync or csrf-csrf (CSRF protection after csurf).

cors() with no options, open to every origin, and Socket.IO with a wildcard origin

An exact allow-list; credentials only with an explicit origin.

Role or owner fields accepted from req.body (mass assignment), password hashes returned in responses, JWTs without expiry kept in localStorage

Allow-listed schemas, response serialisers that omit secrets, short-lived tokens and HttpOnly cookies.

Upload paths built from req.file.originalname and client-supplied MIME types trusted

Generated names, a storage root outside the public folder, content sniffing and size limits.

Stack traces rendered to the browser with an unescaped template tag

Generic production errors and escaped output (Error output and other defaults).

Regular expressions written inline in route strings, relying on path-to-regexp 0.1.x

Express 5 uses path-to-regexp 8, which removes that route-level attack class (Regular-expression denial of service and other known vulnerabilities).

Unmaintained password hashing libraries used synchronously at a low cost factor

An asynchronous bcrypt (cost 12 or more) or argon2id implementation (see Authentication and Authorization).

Dependency auditing never mentioned, with year-old lockfiles

npm audit, npm ci, automated update pull requests (Dependencies).