Skip to content

Latest commit

 

History

History
468 lines (412 loc) · 47.6 KB

nodejs.md

File metadata and controls

468 lines (412 loc) · 47.6 KB

Node.js and Express: TLS and authentication

Node.js runs JavaScript on servers, and Express is a Node.js framework for web apps and APIs. This guide helps your AI assistant deploy them securely, including loopback behind a TLS proxy, a managed platform's required listener or HTTPS in Node, plus Express proxy trust, authentication and verified outbound TLS.

If an AI assistant is helping you secure a deployment, give it the prompt below:

Read the secureconfig guide at
https://raw.githubusercontent.com/jposluns/secureconfig/main/nodejs.md
in full, including the section for AI assistants at the end. Then, for my
deployment: state which of its recommendations apply and the exact changes
to make; compare the versions the guide was written against with the
releases I am running and with the vendor's current releases, and flag any
guidance a newer release may have changed; and run or adapt the guide's
Verify steps where you can. List separately every item you could not
verify instead of reporting it as passed.

Written against Node.js API documentation v24.22.0, express-rate-limit minimum v8, otplib v12.0.1, qrcode v1.5.4, node.bcrypt.js v6.0.0, node-argon2 v0.45.1, Linux man-pages ip(7) 6.19, and Linux man-pages capabilities(7) 6.19, plus 1 more recorded basis; 3 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 22 claims, all reasoned from cited sources.

A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under requests/, or reach the maintainer as CONTRIBUTING.md describes. The machine-readable version basis and claim list are in the section for AI assistants at the end of this guide.

This guide's preferred production layout: bind the Node app to 127.0.0.1 and terminate TLS in a reverse proxy (caddy.md, nginx.md) or behind cloudflare.md. For managed HTTP ingress, this guide advises using the deployment's required listener rather than 127.0.0.1; TLS termination depends on the platform and configuration, as detailed in section 1 (paas.md). Node can also terminate TLS itself, shown below. Get a certificate per free-certificates.md or self-signed.md.

1. HTTPS directly in Node

const https = require('node:https');
const fs = require('node:fs');
const express = require('express');

const app = express();

const options = {
  key:  fs.readFileSync('/etc/ssl/private/server.key'),
  cert: fs.readFileSync('/etc/ssl/certs/server.crt'),   // certificate plus chain
};

https.createServer(options, app).listen(443);

// Port 80 exists only to redirect. Redirect to a FIXED canonical host; never reflect
// req.headers.host, which the client controls (a forged Host, or a cache in front of :80,
// would turn this into an open redirect). Set PUBLIC_HOST to your canonical hostname (no
// scheme or path); the app refuses to start without it rather than emit https://undefined/.
const CANONICAL_HOST = process.env.PUBLIC_HOST;
if (!CANONICAL_HOST) throw new Error('PUBLIC_HOST must be set to the canonical hostname');
require('node:http').createServer((req, res) => {
  // req.url is the raw request target; only an origin-form path is safe to append, so fall
  // back to '/' for absolute-form or asterisk-form (OPTIONS *) targets.
  const path = req.url.startsWith('/') ? req.url : '/';
  res.writeHead(301, { Location: `https://${CANONICAL_HOST}${path}` });
  res.end();
}).listen(80);

On Linux, binding ports below 1024 requires a process, root or not, that has CAP_NET_BIND_SERVICE in the user namespace governing its network namespace (ip(7), capabilities(7); stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout. The listen(443)/listen(80) above bind every interface, which is intended only for this Node-terminates-TLS layout.

This guide's advice for managed platforms: do not add the listen(443)/listen(80) listeners above; bind only the listener required by your deployment. The documented HTTP ingress mappings are:

  • Render web services must bind 0.0.0.0. Render recommends the PORT environment variable (at the time of writing, default port 10000, configurable), can usually detect another bound port, and requires the public HTTP server to use PORT when multiple ports are bound (web services).
  • Heroku assigns each web process its port through PORT (runtime principles), and its Node.js deployment page says the web process must bind to that port or the dyno does not start (deploying Node.js). Fir Private Spaces networking uses IPv6 by default; Heroku warns that IPv4-only assumptions do not bind properly and recommends an IPv6 or dual-stack listener, typically :: (generations). Heroku's HTTP routing page lists IPv6 as unsupported in the Cedar generation without naming a listener address (HTTP routing). This guide's assessment: none of the Heroku pages checked (runtime principles, HTTP routing, generations, dynos, Procfile, Gunicorn deployment, Node.js deployment, the Python and Node.js getting-started tutorials and Node.js best practices, all listed in Sources) states a listener address for Cedar; apart from the Fir recommendation above, the addresses they show are local-development URLs and sample log output. That absence is not evidence that Heroku accepts any listener address.
  • Fly Proxy HTTP services must be reachable over IPv4: use 0.0.0.0 and match the configured internal_port to the app listener (service binding). IPv4-mapped IPv6 wildcard behaviour depends on the VM and runtime (wildcard qualification).
  • Railway domains route to a detected or selected internal target port (target ports). Railway says web servers should bind 0.0.0.0 and listen on the automatically injected PORT (listener and port). Its edge proxy (tcp-proxy) terminates TLS; traffic is then forwarded over Railway's internal network to the deployment (request flow).

Render's load balancer terminates inbound HTTPS and forwards HTTP to the web service (web services). Heroku's HTTP routing page describes a load balancer offering SSL termination and routers forwarding HTTP to web dynos; it notes that both Private Spaces generations have different routers, so this is not a complete account of Private Spaces routing (HTTP routing). Fly Proxy's tls handler terminates TLS and forwards plaintext (TLS handler); without handlers it passes TCP through, allowing the app to terminate TLS itself (TCP passthrough). This guide's advice: plain HTTP is acceptable only behind protected ingress that terminates TLS; application authentication is still required (paas.md).

2. Behind a proxy: tell Express about it

// The app binds to 127.0.0.1 behind a same-host reverse proxy (see caddy.md / nginx.md),
// so trust only loopback addresses. Never use a blanket `true`.
app.set('trust proxy', 'loopback');

// Remote proxy instead? Trust its exact address or subnet:
// app.set('trust proxy', '10.0.0.5');       // single proxy IP
// app.set('trust proxy', '10.0.0.0/24');    // proxy subnet

trust proxy controls how Express derives req.ip, req.ips, req.hostname/req.host (from X-Forwarded-Host), and req.protocol/req.secure (from X-Forwarded-Proto), so a forged header can fake the client IP (defeating rate limits and logging), the hostname, or the HTTPS status. Never set it to true: that trusts the leftmost X-Forwarded-For entry, which the client controls, so clients can forge all of these values unless the last trusted proxy strips or overwrites them. A hop count such as 1 is safe only if every path to the app crosses exactly that many proxies; if a shorter path exists, a client sitting fewer hops away can forge the same headers. Whichever value you use, configure the proxy itself to overwrite inbound X-Forwarded-* headers rather than pass them through.

Security headers, including Strict-Transport-Security, via helmet:

const helmet = require('helmet');
app.use(helmet());

3. Authentication

Follow authentication.md. The pieces most Node projects need:

Password hashing: this guide recommends argon2id (the argon2 package) for new applications and keeping bcrypt for existing bcrypt systems only where Argon2 and scrypt are unavailable, per authentication.md. OWASP recommends Argon2id and limits bcrypt to that legacy scope, with a work factor of at least 10 and a 72-byte password limit (see the pinned Password Storage passages in Sources). The argon2 package at v0.45.1 supports Argon2id and uses it by default.

The example uses the documented bcrypt v6.0.0 CommonJS import, hash(password, cost) and compare(password, hash) APIs. Without a callback, these asynchronous methods return Promises when Promise support is available and support await; compare resolves to whether the password matches the hash. A numeric second argument to hash generates a salt using that cost; the documented work is 2^cost hashing iterations. Cost 12 is this guide's example choice, not an OWASP mandate or a measured optimum for your server:

const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
const ok   = await bcrypt.compare(password, hash);

Sessions with hardened cookies (express-session):

const session = require('express-session');
app.use(session({
  secret: process.env.SESSION_SECRET,      // long random value from the environment
  resave: false,
  saveUninitialized: false,
  // store: a production session store (see below); the default MemoryStore is for development only
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 },   // milliseconds
}));

Express says the default MemoryStore is not designed for production (it leaks memory and does not scale past one process): pass store: a production store such as connect-redis or connect-pg-simple, and set cookie.maxAge so sessions expire, since no maximum age is set by default.

Rate-limit the login route with a current express-rate-limit (v8 or newer masks each IPv6 client to a /56 subnet by default, closing a v7 bypass where a client rotated addresses within its own IPv6 allocation). An IP limit alone does not stop credential-stuffing from many addresses, so pair it with per-account controls (authentication.md):

const rateLimit = require('express-rate-limit');
app.use('/login', rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 }));

This guide advises: load API keys and tokens from process.env, never from literals in the source. Generate them per authentication.md. Where you check them yourself, compare equal-byte-length byte inputs with crypto.timingSafeEqual; it accepts ArrayBuffer, Buffer, TypedArray or DataView inputs, throws on different byte lengths, and does not make surrounding code timing-safe. The use of process.env for secrets and the ban on source literals are this guide's advice; Node.js documents process.env as the user environment and the comparison API's input requirements.

MFA: add TOTP with otplib plus the qrcode package for enrolment QR codes, or front the app with Cloudflare Access and require independent MFA in its effective policy; requirements and options in mfa.md.

Cloudflare Access independent MFA prompts for an additional authentication method before granting application access, without relying on the IdP's MFA configuration. Its MFA settings have precedence policy > application > organization; application and policy settings can turn MFA off (see the pinned independent-MFA and MFA-requirements passages in Sources). This guide requires checking that the effective policy requires MFA and testing that access without the second factor is denied.

This guide also requires that the origin be unreachable around Access. Cloudflare describes Access as an authentication layer between users and the origin, warns that publishing a tunnel route without an Access application exposes it to anyone, and requires other origin-IP protection for an already publicly routable application (see the pinned self-hosted application passages in Sources). These pages do not establish a deployed MFA policy or this application's origin isolation; those remain deployment test requirements, not observed results.

4. Client-side TLS discipline

  • This guide advises: never set NODE_TLS_REJECT_UNAUTHORIZED=0, which disables certificate validation for TLS connections in the Node.js process, and never pass rejectUnauthorized: false, which disables server-certificate verification for that client connection. Never configure an https.Agent with rejectUnauthorized: false: requests using that agent inherit its TLS option. The agent reach is this guide's assessment of Node.js's documented request, agent and TLS options.
  • For an internal CA or self-signed server, this guide advises instead: set NODE_EXTRA_CA_CERTS=/path/ca.crt before starting Node.js, pointing at a PEM file of trusted certificates (see self-signed.md). Node.js adds these to its well-known root CAs at launch; changing the variable at runtime has no effect on the current process. An explicit ca option prevents use of both the well-known and extra CAs for that TLS or HTTPS client or server, and Node.js ignores the variable when run as setuid root or with Linux file capabilities.

5. Verify (REASONED: TLS, headers, authentication, listener and proxy-header expectations follow the Sources below and linked deployment guidance; no deployment run or outcome is recorded in this guide. This metadata-only review has no deployed Node application, proxy or managed ingress to test.)

curl -q -g -sI --noproxy '*' http://example.com/    # expect 301 with an https:// Location
curl -q -g -sI --noproxy '*' https://example.com/   # succeeds without -k; shows helmet's headers

# Auth: probe a REAL protected route (not a placeholder like /api, which often 404s), show the
# status, and disable client proxies so a denial is attributable. A denial from ANY layer (the app,
# or a proxy/Access in front) is the pass; the positive control proves auth actually gates rather
# than the route being broken for everyone.
curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' 'https://example.com/REPLACE_WITH_PROTECTED_PATH'
                                     # expect 401 or 403 with no credentials
curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' \
  -b REPLACE_WITH_SESSION_COOKIE_FILE 'https://example.com/REPLACE_WITH_PROTECTED_PATH'
                                     # expect 200 with a valid session (cookie read from a file, not argv;
                                     # for a bearer-token route use -H @REPLACE_WITH_AUTH_HEADER_FILE instead of -b)

ss -tlnp   # read every listener; a same-host proxy layout binds 127.0.0.1 only, on managed
           # ingress the platform's required address and port from section 1
# trust proxy: add a temporary route that echoes req.ip, then remove it after this check
#   app.get('/whoami', (req, res) => res.send(req.ip))
curl -q -g -sS --noproxy '*' -H 'X-Forwarded-For: 203.0.113.9' https://example.com/whoami
                                     # req.ip must be your real client IP, never 203.0.113.9: echoing the
                                     # forged value means `trust proxy` is too broad and trusts a
                                     # client-set header. This checks req.ip scoping only, not that the
                                     # proxy strips headers, and does not cover X-Forwarded-Host or -Proto.
                                     # An overwriting proxy can also mask a too-broad setting, so a clean
                                     # result here does not prove the trust boundary by itself

Sources (checked September 2026)

About the AI assistant section below

This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use the prompt at the top. This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard.

Version basis

AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist.

DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded.

Components (recorded basis):

  • node-v24: Node.js API documentation (v24.22.0)
  • node: Node.js documentation (unknown)
  • express: Express proxy documentation (unknown)
  • session: express-session documentation (unknown)
  • helmet: Helmet documentation (unknown)
  • rate: express-rate-limit minimum (v8)
  • otplib: otplib (v12.0.1)
  • qrcode: qrcode (v1.5.4)
  • render: Render documentation (unknown)
  • heroku: Heroku documentation (unknown)
  • fly-pinned: Fly.io pinned documentation (2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9)
  • railway: Railway documentation (72354493198302677fb6be6c692dd75c64cae2c6)
  • bcrypt: node.bcrypt.js (v6.0.0)
  • argon2: node-argon2 (v0.45.1)
  • cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104)
  • linux-ip: Linux man-pages ip(7) (6.19)
  • linux-capabilities: Linux man-pages capabilities(7) (6.19)
  • owasp: OWASP password storage cheat sheet (commit 72865b1e)

Claims (full source mapping in the JSON block below):

  • tls (REASONED; node): Direct HTTPS uses a certificate plus chain and private key on all-interface 443.
  • redirect (REASONED; node): All-interface 80 redirects with 301 to required PUBLIC_HOST; only origin-form request paths are appended.
  • bind (REASONED; node, express, render, heroku, fly-pinned, railway): Guide advice: 127.0.0.1 for a same-host proxy, no extra managed listeners, app auth required; plain HTTP needs protected TLS ingress, absent on Fly without handlers. Railway advises 0.0.0.0 on injected PORT with edge-proxy TLS; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's assessment, not proof any address works).
  • privilege (REASONED; node, linux-ip, linux-capabilities): On Linux, binding ports below 1024 requires a process, root or not, that has CAP_NET_BIND_SERVICE in the user namespace governing its network namespace (stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout.
  • proxy (REASONED; express): Trust loopback or an exact remote proxy address/subnet; true and variable-length hop paths permit forged client IP, host and scheme unless headers are overwritten.
  • headers (REASONED; helmet): Install helmet() for security headers including HSTS.
  • password (REASONED; session, owasp, bcrypt, argon2): Guide advice: prefer Argon2id for new apps (argon2 v0.45.1 supports it); retain bcrypt only for legacy systems without Argon2/scrypt, with its 72-byte limit and example cost 12 (OWASP minimum 10). bcrypt v6.0.0 hash/compare return Promises without callbacks when supported; numeric hash cost generates a salt and means 2^cost iterations.
  • session-secret (REASONED; session): Load a long random SESSION_SECRET from the environment.
  • session-save (REASONED; session): Set resave=false and saveUninitialized=false.
  • cookies (REASONED; session): Set Secure, HttpOnly, SameSite=lax and an eight-hour cookie maxAge in milliseconds.
  • store (REASONED; session): Default MemoryStore is development-only, leaks memory and cannot scale past one process; supply a production store.
  • expiry (REASONED; session): No cookie maximum age is set by default; set cookie.maxAge.
  • login-limit (REASONED; rate): Use a 20-request, 15-minute login limit; v8 or newer defaults to IPv6 /56 grouping, closing the v7 rotation bypass; pair IP limits with account controls.
  • tokens (REASONED; node, node-v24): This guide advises loading API keys and tokens from process.env, never from source literals, and comparing self-checked secret values with crypto.timingSafeEqual using equal-byte-length byte inputs; process.env exposes the user environment, while timingSafeEqual's input requirements and surrounding-code limit are documented.
  • mfa (REASONED; session, otplib, qrcode, cf-docs): Add otplib/qrcode TOTP or require Cloudflare Access independent MFA in the effective policy (policy > application > organization). This guide requires an origin unreachable around Access and testing enforcement; cited pages do not establish a deployed policy or origin isolation.
  • tls-validation (REASONED; node, node-v24): This guide advises never setting NODE_TLS_REJECT_UNAUTHORIZED=0 for the Node.js process's TLS connections or rejectUnauthorized=false for a client TLS connection or an HTTPS agent whose requests use it; Node.js documents the TLS validation effects, and the agent reach is this guide's assessment of the documented options flow.
  • client-ca (REASONED; node, node-v24): This guide advises setting NODE_EXTRA_CA_CERTS to a PEM file before launching Node.js for an internal CA or self-signed server; it extends well-known roots, is read only at launch, and is ignored when Node.js runs as setuid root or with Linux file capabilities; neither it nor the well-known roots is used by a TLS or HTTPS client or server given an explicit ca option.
  • verify-redirect (REASONED; node): HTTP should return 301 with an HTTPS Location.
  • verify-tls (REASONED; node, helmet): HTTPS must succeed without -k and show Helmet headers.
  • verify-auth (REASONED; session): A real protected route denies anonymous requests with 401/403 and accepts a valid session with 200; either app or fronting auth may enforce the denial.
  • verify-bind (REASONED; node, express): Inspect every listener for loopback behind a same-host proxy or the managed platform address and port.
  • verify-proxy (REASONED; express): Temporary /whoami must not echo forged X-Forwarded-For; this checks req.ip only and an overwriting proxy can mask excessive trust.
{
  "schema": 1,
  "checked": "2026-10-21",
  "documentation_checked": "2026-09",
  "body_sha256": "e1de61462029418be38da3f55d3604a8ffedbe79642c0d8c9febe58f9985d6e7",
  "components": {
    "node-v24": {
      "name": "Node.js API documentation",
      "basis": "v24.22.0",
      "sources": {
        "s670c858d43f0": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/process.md#L1563-L1581",
        "s2f3ecf558ce7": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/crypto.md#L6678-L6717",
        "s7fbb99765dc4": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/cli.md#L3568-L3588",
        "sbfee17929443": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/cli.md#L3940-L3944",
        "s684a71cf8b6e": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/tls.md#L1786-L1789",
        "sea85e903cce0": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/https.md#L70-L103",
        "sd0e5679b6277": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/https.md#L458-L472",
        "sd9044fe58c3e": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/https.md#L533-L549"
      }
    },
    "node": {
      "name": "Node.js documentation",
      "basis": "unknown",
      "sources": {
        "s87db42190978": "https://nodejs.org/api/https.html",
        "sdb035b84e308": "https://nodejs.org/api/http.html"
      }
    },
    "express": {
      "name": "Express proxy documentation",
      "basis": "unknown",
      "sources": {
        "s2acbdbd05858": "https://expressjs.com/en/guide/behind-proxies.html"
      }
    },
    "session": {
      "name": "express-session documentation",
      "basis": "unknown",
      "sources": {
        "safd9f9d15304": "https://expressjs.com/en/resources/middleware/session/"
      }
    },
    "helmet": {
      "name": "Helmet documentation",
      "basis": "unknown",
      "sources": {
        "sc1865315478e": "https://helmet.js.org/"
      }
    },
    "rate": {
      "name": "express-rate-limit minimum",
      "basis": "v8",
      "sources": {
        "s303dcc57a0df": "https://express-rate-limit.mintlify.app/reference/changelog"
      }
    },
    "otplib": {
      "name": "otplib",
      "basis": "v12.0.1",
      "sources": {
        "sdb2cd721be64": "https://github.com/yeojz/otplib/blob/v12.0.1/README.md#L371-L372"
      }
    },
    "qrcode": {
      "name": "qrcode",
      "basis": "v1.5.4",
      "sources": {
        "sc505636608da": "https://github.com/soldair/node-qrcode/blob/v1.5.4/README.md#L125-L129"
      }
    },
    "render": {
      "name": "Render documentation",
      "basis": "unknown",
      "sources": {
        "s1820ac471b6c": "https://render.com/docs/web-services"
      }
    },
    "heroku": {
      "name": "Heroku documentation",
      "basis": "unknown",
      "sources": {
        "s370d4beec29a": "https://devcenter.heroku.com/articles/runtime-principles",
        "s211f5fe76042": "https://devcenter.heroku.com/articles/generations",
        "s0c5bf300cef4": "https://devcenter.heroku.com/articles/http-routing",
        "s110a083f7129": "https://devcenter.heroku.com/articles/deploying-nodejs",
        "s41677c652b96": "https://devcenter.heroku.com/articles/dynos",
        "s9f4dec283014": "https://devcenter.heroku.com/articles/procfile",
        "s8a2465cbd6c2": "https://devcenter.heroku.com/articles/python-gunicorn",
        "sb941efbc6dd5": "https://devcenter.heroku.com/articles/getting-started-with-python",
        "sa2690945ce1c": "https://devcenter.heroku.com/articles/getting-started-with-nodejs",
        "s3563a06d3934": "https://devcenter.heroku.com/articles/node-best-practices"
      }
    },
    "fly-pinned": {
      "name": "Fly.io pinned documentation",
      "basis": "2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9",
      "sources": {
        "s62fec91543a1": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42",
        "s61c1e50d2c11": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92",
        "s254c8c8a5edd": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97",
        "s32999aa4c58c": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176"
      }
    },
    "railway": {
      "name": "Railway documentation",
      "basis": "72354493198302677fb6be6c692dd75c64cae2c6",
      "sources": {
        "sac1383056b65": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146",
        "s0462eef65b1b": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51",
        "sa9d326046a7f": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43"
      }
    },
    "bcrypt": {
      "name": "node.bcrypt.js",
      "basis": "v6.0.0",
      "sources": {
        "se94486b19d69": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L104",
        "s228d1e5e8827": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L148-L181",
        "s60f2c824a85c": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L248-L262",
        "s55a49cad0981": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L268"
      }
    },
    "argon2": {
      "name": "node-argon2",
      "basis": "v0.45.1",
      "sources": {
        "s76b67b13ea1b": "https://github.com/ranisalt/node-argon2/blob/v0.45.1/README.md#L12-L13"
      }
    },
    "cf-docs": {
      "name": "Cloudflare documentation",
      "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104",
      "sources": {
        "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24",
        "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81",
        "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16",
        "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54"
      }
    },
    "linux-ip": {
      "name": "Linux man-pages ip(7)",
      "basis": "6.19",
      "sources": {
        "s612fed1df1ed": "https://man7.org/linux/man-pages/man7/ip.7.html"
      }
    },
    "linux-capabilities": {
      "name": "Linux man-pages capabilities(7)",
      "basis": "6.19",
      "sources": {
        "s3c3a13034c04": "https://man7.org/linux/man-pages/man7/capabilities.7.html"
      }
    },
    "owasp": {
      "name": "OWASP password storage cheat sheet",
      "basis": "commit 72865b1e",
      "sources": {
        "s790d2fc1475e": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L11",
        "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13",
        "sad2af32bee2f": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L136-L138"
      }
    }
  },
  "claims": {
    "tls": {"text": "Direct HTTPS uses a certificate plus chain and private key on all-interface 443.", "components": ["node"], "sources": ["node:s87db42190978"], "status": "REASONED"},
    "redirect": {"text": "All-interface 80 redirects with 301 to required PUBLIC_HOST; only origin-form request paths are appended.", "components": ["node"], "sources": ["node:sdb035b84e308"], "status": "REASONED"},
    "bind": {"text": "Guide advice: 127.0.0.1 for a same-host proxy, no extra managed listeners, app auth required; plain HTTP needs protected TLS ingress, absent on Fly without handlers. Railway advises 0.0.0.0 on injected PORT with edge-proxy TLS; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's assessment, not proof any address works).", "components": ["node", "express", "render", "heroku", "fly-pinned", "railway"], "sources": ["node:s87db42190978", "express:s2acbdbd05858", "render:s1820ac471b6c", "heroku:s370d4beec29a", "heroku:s211f5fe76042", "heroku:s0c5bf300cef4", "heroku:s110a083f7129", "heroku:s41677c652b96", "heroku:s9f4dec283014", "heroku:s8a2465cbd6c2", "heroku:sb941efbc6dd5", "heroku:sa2690945ce1c", "heroku:s3563a06d3934", "fly-pinned:s62fec91543a1", "fly-pinned:s61c1e50d2c11", "fly-pinned:s254c8c8a5edd", "fly-pinned:s32999aa4c58c", "railway:sac1383056b65", "railway:s0462eef65b1b", "railway:sa9d326046a7f"], "status": "REASONED"},
    "privilege": {"text": "On Linux, binding ports below 1024 requires a process, root or not, that has CAP_NET_BIND_SERVICE in the user namespace governing its network namespace (stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout.", "components": ["node", "linux-ip", "linux-capabilities"], "sources": ["node:s87db42190978", "linux-ip:s612fed1df1ed", "linux-capabilities:s3c3a13034c04"], "status": "REASONED"},
    "proxy": {"text": "Trust loopback or an exact remote proxy address/subnet; true and variable-length hop paths permit forged client IP, host and scheme unless headers are overwritten.", "components": ["express"], "sources": ["express:s2acbdbd05858"], "status": "REASONED"},
    "headers": {"text": "Install helmet() for security headers including HSTS.", "components": ["helmet"], "sources": ["helmet:sc1865315478e"], "status": "REASONED"},
    "password": {"text": "Guide advice: prefer Argon2id for new apps (argon2 v0.45.1 supports it); retain bcrypt only for legacy systems without Argon2/scrypt, with its 72-byte limit and example cost 12 (OWASP minimum 10). bcrypt v6.0.0 hash/compare return Promises without callbacks when supported; numeric hash cost generates a salt and means 2^cost iterations.", "components": ["session", "owasp", "bcrypt", "argon2"], "sources": ["session:safd9f9d15304", "owasp:s790d2fc1475e", "owasp:s4531e65b6502", "owasp:sad2af32bee2f", "bcrypt:se94486b19d69", "bcrypt:s228d1e5e8827", "bcrypt:s60f2c824a85c", "bcrypt:s55a49cad0981", "argon2:s76b67b13ea1b"], "status": "REASONED"},
    "session-secret": {"text": "Load a long random SESSION_SECRET from the environment.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"},
    "session-save": {"text": "Set resave=false and saveUninitialized=false.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"},
    "cookies": {"text": "Set Secure, HttpOnly, SameSite=lax and an eight-hour cookie maxAge in milliseconds.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"},
    "store": {"text": "Default MemoryStore is development-only, leaks memory and cannot scale past one process; supply a production store.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"},
    "expiry": {"text": "No cookie maximum age is set by default; set cookie.maxAge.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"},
    "login-limit": {"text": "Use a 20-request, 15-minute login limit; v8 or newer defaults to IPv6 /56 grouping, closing the v7 rotation bypass; pair IP limits with account controls.", "components": ["rate"], "sources": ["rate:s303dcc57a0df"], "status": "REASONED"},
    "tokens": {"text": "This guide advises loading API keys and tokens from process.env, never from source literals, and comparing self-checked secret values with crypto.timingSafeEqual using equal-byte-length byte inputs; process.env exposes the user environment, while timingSafeEqual's input requirements and surrounding-code limit are documented.", "components": ["node", "node-v24"], "sources": ["node:s87db42190978", "node-v24:s670c858d43f0", "node-v24:s2f3ecf558ce7"], "status": "REASONED"},
    "mfa": {"text": "Add otplib/qrcode TOTP or require Cloudflare Access independent MFA in the effective policy (policy > application > organization). This guide requires an origin unreachable around Access and testing enforcement; cited pages do not establish a deployed policy or origin isolation.", "components": ["session", "otplib", "qrcode", "cf-docs"], "sources": ["session:safd9f9d15304", "otplib:sdb2cd721be64", "qrcode:sc505636608da", "cf-docs:s7e793d13cf31", "cf-docs:sdcac732541da", "cf-docs:sfddb92e438ca", "cf-docs:s7cafe1b17afd"], "status": "REASONED"},
    "tls-validation": {"text": "This guide advises never setting NODE_TLS_REJECT_UNAUTHORIZED=0 for the Node.js process's TLS connections or rejectUnauthorized=false for a client TLS connection or an HTTPS agent whose requests use it; Node.js documents the TLS validation effects, and the agent reach is this guide's assessment of the documented options flow.", "components": ["node", "node-v24"], "sources": ["node:s87db42190978", "node-v24:sbfee17929443", "node-v24:s684a71cf8b6e", "node-v24:sea85e903cce0", "node-v24:sd0e5679b6277", "node-v24:sd9044fe58c3e"], "status": "REASONED"},
    "client-ca": {"text": "This guide advises setting NODE_EXTRA_CA_CERTS to a PEM file before launching Node.js for an internal CA or self-signed server; it extends well-known roots, is read only at launch, and is ignored when Node.js runs as setuid root or with Linux file capabilities; neither it nor the well-known roots is used by a TLS or HTTPS client or server given an explicit ca option.", "components": ["node", "node-v24"], "sources": ["node:s87db42190978", "node-v24:s7fbb99765dc4"], "status": "REASONED"},
    "verify-redirect": {"text": "HTTP should return 301 with an HTTPS Location.", "components": ["node"], "sources": ["node:sdb035b84e308"], "status": "REASONED", "verify": [1]},
    "verify-tls": {"text": "HTTPS must succeed without -k and show Helmet headers.", "components": ["node", "helmet"], "sources": ["node:s87db42190978", "helmet:sc1865315478e"], "status": "REASONED", "verify": [1]},
    "verify-auth": {"text": "A real protected route denies anonymous requests with 401/403 and accepts a valid session with 200; either app or fronting auth may enforce the denial.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED", "verify": [1]},
    "verify-bind": {"text": "Inspect every listener for loopback behind a same-host proxy or the managed platform address and port.", "components": ["node", "express"], "sources": ["node:s87db42190978", "express:s2acbdbd05858"], "status": "REASONED", "verify": [1]},
    "verify-proxy": {"text": "Temporary /whoami must not echo forged X-Forwarded-For; this checks req.ip only and an overwriting proxy can mask excessive trust.", "components": ["express"], "sources": ["express:s2acbdbd05858"], "status": "REASONED", "verify": [1]}
  }
}