Fix cards for Node.js and Express
A01:2025 Broken Access Control
- Cross-site request forgery (CSRF)CWE-352
Express has no built-in CSRF protection, so add one middleware before every route: let POST, PUT, PATCH and DELETE through only when Sec-Fetch-Site is same-origin (or none), fall back to an exact Origin match when the browser sends no Sec-Fetch-Site, and refuse the rest with 403.
- Missing object-level authorization (IDOR and BOLA)CWE-862 · CWE-639 · CWE-863
Take the user from your authentication middleware (req.user), never from the request, and put the same scope in every invoice query: tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin).
- Open redirectCWE-601
Resolve next against your own origin with new URL(next, site), the WHATWG parser browsers use, and redirect only if url.origin is in an exact allowlist and the URL carries no user name or password; otherwise redirect to a fixed page.
- Path traversalCWE-22 · CWE-23 · CWE-36 · CWE-59
Check where the path lands, not what it looks like: resolve base with fs.realpath, resolve the request with realpath(path.resolve(base, name)), which collapses ../ and follows symlinks, and serve it only if it starts with base + path.sep.
- Server-side request forgery (SSRF)CWE-918
Attach the policy to the connection, not the hostname string.
A02:2025 Security Misconfiguration
- CORS misconfiguration on credentialed APIsCWE-942 · CWE-346
Use the cors middleware with an origin function that checks an exact allowlist of full origins from configuration, and credentials: true.
A04:2025 Cryptographic Failures
- Insecure randomness for security tokensCWE-330 · CWE-338
Generate the token with a CSPRNG: crypto.randomBytes(32).toString('base64url') is 256 bits (prefer it over crypto.randomUUID, which carries only 122).
- Weak password hashingCWE-916 · CWE-759 · CWE-327
Hash with Argon2id at or above OWASP's minimum: argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }).
A05:2025 Injection
- Code injectionCWE-94 · CWE-95
Do not turn a request string into code. eval, new Function and node:vm all run it, and node:vm is not a sandbox.
- Cross-site scripting (XSS)CWE-79
Print every field through EJS's escaping tag <%= %>, which turns & < > " and ' into entities, and never through the raw tag <%- %>.
- CSV formula injectionCWE-1236
Spreadsheets run a cell that starts with =, +, -, @, tab or carriage return.
- NoSQL operator injectionCWE-943
Never let a request value reach a MongoDB filter as an object: accept strings only (typeof value === 'string'), compare with { field: { $eq: value } }, and check secrets such as reset tokens in code with timingSafeEqual after looking the account up by email.
- OS command and argument injectionCWE-78 · CWE-88
Run the program directly with its arguments as a list - execFileSync(tool, ['--', name]) - so no shell parses them, and put '--' before any user operand so a leading dash cannot pose as an option.
- Server-side template injectionCWE-1336 · CWE-94 · CWE-95
Don't compile input as a template: ejs.render(userTemplate) runs any <%= ... %> as JavaScript.
- SQL injectionCWE-89
Bind every value: put ? placeholders in the SQL and pass the values to node:sqlite, as in db.prepare(sql).all(name, status).
A06:2025 Insecure Design
- ClickjackingCWE-1021
Send a framing policy on sensitive pages. Content-Security-Policy: frame-ancestors 'none' (or 'self' and named partners) is what modern browsers honor; add X-Frame-Options: DENY for old ones.
- Regular-expression denial of service (ReDoS)CWE-1333
A validation regex with a nested quantifier backtracks catastrophically: a 40-character string pins a CPU for minutes and, on Node's single event loop, blocks every request.
- Unbounded resource consumptionCWE-770 · CWE-400
Bound every resource a request can consume: cap the body size as it is read (express.json's limit), cap the work one request asks for (a maximum item count), and rate-limit callers keyed on the connection address, not a header the client sets.
- Unrestricted file uploadCWE-434 · CWE-646 · CWE-73 · CWE-770
Decide the type from the bytes, and never let the client pick the name.
A07:2025 Authentication Failures
- Hard-coded secretsCWE-798 · CWE-260
Read the key from process.env at startup, where the deployment puts it from your secret store, and throw if it is missing or blank so the app never starts without it.
- Improper TLS certificate validationCWE-295 · CWE-297 · CWE-296
Never turn verification off. Give the partner client an https.Agent whose ca is the partner's private CA bundle, set rejectUnauthorized: true explicitly so NODE_TLS_REJECT_UNAUTHORIZED=0 cannot flip it, and keep the default checkServerIdentity.
- JWT validation in APIs (bearer tokens)CWE-347 · CWE-287
Verify every bearer token with jose's jwtVerify(): pass the issuer's public key from configuration, algorithms: ['RS256'], issuer and audience, requiredClaims: ['exp', 'sub'] and a small clockTolerance.
- Missing authentication for a critical functionCWE-306 · CWE-287
Verify a credential on the server before any critical route runs.
- Session fixation and session cookiesCWE-384 · CWE-613 · CWE-614 · CWE-1004 · CWE-1275
In the sign-in handler, call req.session.regenerate(), then store the user and save, so no session ID from before sign-in survives.
A08:2025 Software or Data Integrity Failures
- Insecure deserializationCWE-502
Treat the token as data: JSON.parse it and rebuild only an allowlisted type - map $type to one known class, never the class the payload names.
- Mass assignment (over-posting)CWE-915
Never pass req.body to Object.assign, a spread or an ORM create or update on a stored record.
A09:2025 Security Logging and Alerting Failures
- CRLF injection in logs and response headersCWE-117 · CWE-93 · CWE-113
Log with a structured logger such as pino, so every field is JSON-encoded and a CR, LF or escape character in a value can never end the record.
Not covered in Node.js yet
These weaknesses have fix cards in other stacks but not in this one.
- XML external entity (XXE) processing: No Node.js XML parser that resolves attacker-controlled entities installs cleanly on Node 26, so a vulnerable-vs-fixed pair cannot be built honestly on this runtime. Verified: libxmljs2 0.37.0 (the libxml2 binding) fails to compile against Node 26.3.0's V8 ABI (node-gyp build error, GetAlignedPointerFromInternalField signature change); fast-xml-parser 5.3.6 does not expand DOCTYPE-declared entities (it keeps '&c;' literal) and throws 'External entities are not supported' (only programmatic addEntity expands), and its entity-expansion flaw CVE-2026-26278 affects only the older 4.1.3-5.3.5 range, which the pinning rule forbids shipping; @xmldom/xmldom 0.9.8 reports 'entity not found' for custom and external entities and resolves neither. That pure-JS XML parsers do not dereference external entities is itself a useful defensive fact: on Node the exposure is overwhelmingly entity-expansion (CWE-776) in older or misconfigured libraries, addressed by keeping the parser current and disabling DOCTYPE processing.
- LDAP filter injection
- XPath injection
Supported versions
Only the latest release of each supported major line gets fixes; the Express team lists 5.x and 4.x as supported with no end date. Express support policy.
| Version | Latest | Status | Released | End of support |
|---|---|---|---|---|
| 5.x | 5.3.0 | SupportedCurrent major; the cards use it. | 10 September 2024 | No date set |
| 4.x | 4.22.3 | Supported | 9 April 2014 | No date set |
| Version | Latest | Status | Released | End of support |
|---|---|---|---|---|
| 26 | 26.11.1 | SupportedCurrent release until 2026-10-27; Active LTS from 2026-10-28 and Maintenance LTS from 2027-10-20 on the release schedule. | 5 May 2026 | 30 April 2029 |
| 24 | 24.21.0 | SupportedActive LTS until 2026-10-19; Maintenance LTS from 2026-10-20 on the release schedule. | 6 May 2025 | 30 April 2028 |
| 22 | 22.23.3 | MaintenanceMaintenance LTS since 2025-10-21. | 24 April 2024 | 30 April 2027 |
| 20 | 20.20.2 | End of life | 18 April 2023 | 30 April 2026 |
Production applications should run an Active or Maintenance LTS line. The Node.js release schedule moves Node.js 24 from Active to Maintenance LTS on 20 October 2026 and Node.js 26 from Current to Active LTS on 28 October 2026. Node.js release schedule
Minimum secure versions
The lowest release in each supported line that fixes every published advisory listed here. Anything older is missing a security fix.
| Package | At least | Why |
|---|---|---|
express | 5.3.0 | Requires proxy-addr 2.0.8 (CVE-2026-90711, IP spoofing), qs 6.16.0 (CVE-2026-82417, CVE-2026-82562 and CVE-2026-2391, denial of service) and body-parser 2.3.0 (CVE-2026-12590); npm ls qs should show only 6.16.0. Advisory |
express | 4.22.3 | Requires qs 6.16.0 and path-to-regexp 0.1.13 (CVE-2026-4867). Refresh the lockfile until proxy-addr is 2.0.8 (CVE-2026-90711) and body-parser 1.20.8: 1.20.6 still pulls a vulnerable qs 6.15.3. Advisory |
node | 22.23.2, 24.18.1 or 26.5.1 | The 29 July 2026 security releases, the latest on 11 October 2026: an HTTP/2 use-after-free (CVE-2026-56848), a Permission Model bypass (CVE-2026-58043), HTTP/2 memory exhaustion in 22.x and 24.x (CVE-2026-56846) and eight lower-severity CVEs. Advisory |
Middleware and security order
- Trust proxy: Set
app.set('trust proxy', 1)to the number of proxies you run before anything readsreq.iporreq.protocol; express-session only sends asecurecookie when Express knows the request arrived over HTTPS. - Security headers: Mount
helmet()first, so every response, including 404s and errors, carries the headers; it also removesX-Powered-By. - Body parsers with limits:
express.json()andexpress.urlencoded({ extended: false })with an explicitlimit(the default is 100kb), only on routes that take a body. - Session:
express-sessionwith a production store (the default MemoryStore is not designed for production),secure,httpOnly,sameSiteand a generic cookiename. - CSRF protection and rate limits: After the session and body parsers and before the routes that change state; rate-limit sign-in and other credential endpoints.
- Routes: Authenticate in router-level middleware and check object ownership inside each handler or its data-access call.
- 404 handler, then the error handler: A four-argument error handler last, returning a generic message; run with
NODE_ENV=productionso the built-in handler never sends stack traces.
Secure defaults and settings
| Setting | Value | Why |
|---|---|---|
query parser | 'simple' (Express 5) | Keep it. The 'extended' (qs) parser turns a[b]=c into nested objects, which is how operator objects reach NoSQL queries. |
x-powered-by | Enabled | Disable it with app.disable('x-powered-by') or Helmet. |
express-session cookie | httpOnly: true, secure: false, no sameSite | Set secure: true and sameSite: 'lax', and regenerate the session at sign-in. |
express.json() limit | '100kb' | Set the smallest limit each route needs. |
NODE_ENV | Unset (development) | Set production; otherwise the built-in error handler sends stack traces to the client. |
Escape hatches to search for
Framework APIs that switch a protection off. Each one is sometimes needed; search for them in review and make each use justify itself.
| API | Risk | Instead |
|---|---|---|
EJS <%- value %> | Writes the value without HTML escaping. | <%= value %>, or a sanitizer for HTML you must render. |
res.send() with HTML built in a template string | No escaping at all. | A template engine that escapes by default. |
cors({ origin: true, credentials: true }) | Reflects any Origin and allows credentials. | An explicit list of origins. |
app.set('trust proxy', true) | Trusts any X-Forwarded-For, so clients choose their own req.ip. | The hop count or the proxy's address range. |
app.set('query parser', 'extended') | Query values become objects and arrays the code does not expect. | The simple parser and type checks on every value. |
express.static(root, { dotfiles: 'allow' }) | Serves .env and .git files. | The default 'ignore', and a static root that holds only public files. |
Checklist, mapped to ASVS 5.0.0
- v5.0.0-3.4.1 Every response carries
Strict-Transport-Securitywith a max-age of at least a year (Helmet's default is 365 days withincludeSubDomains). - v5.0.0-3.3.1 Session cookies are
Secureand use the__Host-or__Secure-prefix. - v5.0.0-7.2.4 Sign-in calls
req.session.regenerate()so the user gets a new session ID. - v5.0.0-3.5.1 State-changing requests from browsers carry a CSRF token or pass an Origin check.
- v5.0.0-1.2.4 Every query binds its values; no SQL is built with template literals.
- v5.0.0-8.2.2 Each handler checks that the caller owns the object it reads or changes.
- v5.0.0-15.3.3 Request bodies are parsed into a schema that lists the fields a caller may set.
- v5.0.0-4.1.3
trust proxymatches your proxies, so clients cannot set their own address or scheme. - v5.0.0-16.5.1 Errors reach the client as a generic message; details go to the server log.