Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix TLS certificate validation in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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. Node then checks the chain, the dates and the host name, and any other certificate fails the handshake before the token is sent.

On this page

Steps

  1. Find every place that loosens TLS: rejectUnauthorized: false (in https.Agent, axios's httpsAgent, got's https options, tls.connect), checkServerIdentity overrides, and NODE_TLS_REJECT_UNAUTHORIZED in Dockerfiles, .env files, CI settings and process managers.
  2. For a public service, delete the override: the default trust store and checks are already right.
  3. For a private service, create one https.Agent for that client with ca: readFileSync(<partner CA bundle>). ca replaces the bundled Mozilla roots for that agent only, so other clients keep the public roots and the private CA cannot vouch for public hosts.
  4. Set rejectUnauthorized: true on that agent. It is the default, but NODE_TLS_REJECT_UNAUTHORIZED=0 changes the default for every client in the process.
  5. Leave checkServerIdentity alone. If the certificate does not name the host you connect to, add the name to its subjectAltName or connect by the name it carries.
  6. The global fetch() ignores https.Agent. To pin a CA for fetch, pass a dispatcher built with the undici package's Agent({ connect: { ca } }), or use node:https as this card does.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export function createPartnerAgent() {
  // Staging failed with "self-signed certificate in certificate chain", so verification
  // was turned off. This is the bug: every certificate is accepted, for any host name.
  return new https.Agent({ rejectUnauthorized: false });
}

With IMPL=vulnerable, 5 of 6 tests fail (5 exploit tests).

The fix: trust only the partner's CA

Fixedimpl/fixed.jsJavaScript
export function createPartnerAgent({ caFile }) {
  return new https.Agent({
    // Replaces Node's bundled Mozilla roots and NODE_EXTRA_CA_CERTS for this agent only, so
    // a certificate from any other CA, public or private, is refused.
    ca: readFileSync(caFile),
    // Already the default, but NODE_TLS_REJECT_UNAUTHORIZED=0 in the environment flips the
    // default for the whole process. Saying it here keeps this client verifying anyway.
    rejectUnauthorized: true,
    // checkServerIdentity keeps its default, tls.checkServerIdentity. Never override it.
  });
}

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export function createPartnerAgent({ caFile }) {
  return new https.Agent({
    ca: readFileSync(caFile),
    // We reach the partner through an internal alias, so the name in its certificate
    // never matched and every call failed with ERR_TLS_CERT_ALTNAME_INVALID. Returning
    // undefined tells Node the name matched.
    checkServerIdentity: () => undefined,
  });
}

With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/rates.test.jsJavaScript
// Each server below answers at api.partner.test but must not be trusted. The client has
// to refuse the TLS handshake before it sends the request carrying the bearer token.
describe(`exploit (IMPL=${implName})`, () => {
  test('exploit: a self-signed certificate for the partner host name is refused', async () => {
    await withPartner('selfSigned', async ({ getRates, server }) => assertRefused(await getRates(), server));
  });

  test('exploit: a certificate from the trusted CA for another host name is refused', async () => {
    await withPartner('otherHost', async ({ getRates, server }) => assertRefused(await getRates(), server));
  });

  test('exploit: a certificate from an unknown CA named like the trusted CA is refused', async () => {
    await withPartner('rogueCa', async ({ getRates, server }) => assertRefused(await getRates(), server));
  });

  test('exploit: an expired certificate from the trusted CA is refused', async () => {
    await withPartner('expired', async ({ getRates, server }) => assertRefused(await getRates(), server));
  });

  // Node only: a leftover NODE_TLS_REJECT_UNAUTHORIZED=0 (from a Dockerfile or a .env file)
  // turns off verification for every client that relies on the default.
  test('exploit: NODE_TLS_REJECT_UNAUTHORIZED=0 in the environment does not switch verification off', async () => {
    const saved = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
    process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
    try {
      await withPartner('selfSigned', async ({ getRates, server }) => assertRefused(await getRates(), server));
    } finally {
      if (saved === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
      else process.env.NODE_TLS_REJECT_UNAUTHORIZED = saved;
    }
  });
});

Pitfalls

  • rejectUnauthorized: false accepts every certificate: self-signed, expired, issued by anyone, for any host. It is the most common way TLS gets switched off in Node, often inside an axios or got configuration.
  • NODE_TLS_REJECT_UNAUTHORIZED=0 turns verification off for every client in the process that relies on the default, and Node prints a single warning. An explicit rejectUnauthorized: true on the agent keeps that client verifying (checked on Node 26.11.1).
  • checkServerIdentity: () => undefined keeps the chain check but accepts any certificate the trusted CA has issued, for any host. Against the public roots, that is any certificate any public CA has issued to anyone.
  • ca replaces the default roots for that agent rather than adding to them. To trust a private CA as well as the public roots, concatenate it with tls.getCACertificates('default'), and only for the clients that need both.
  • NODE_EXTRA_CA_CERTS adds a CA for every client in the process and is read once at startup; Node ignores it for an agent that sets ca.
  • A self-signed certificate is not 'encrypted but unverified' in any useful sense: without a trusted issuer, anyone can make one with the same name in seconds and sit in the middle.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs tls-certificate-validation/node-express does this for you).
  2. The exploit tests serve a self-signed certificate for api.partner.test, a trusted-CA certificate for billing.partner.test, a certificate from an unknown CA named like the trusted one and an expired certificate, and check that GET /rates returns 502 and no request reached the server. One more test repeats the self-signed case with NODE_TLS_REJECT_UNAUTHORIZED=0 set.
  3. Against your own service, point the client at a server with a self-signed certificate for the real name and expect DEPTH_ZERO_SELF_SIGNED_CERT, then at a valid certificate for another name and expect ERR_TLS_CERT_ALTNAME_INVALID.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix TLS certificate validation in Node.js and Express
ToolRuleWhat it found here
Semgrepproblem-based-packs.insecure-transport.js-node.bypass-tls-verification.bypass-tls-verification1 finding: impl/vulnerable.js line 11 (rejectUnauthorized: false). None on fixed.js or wrong.js. The rule matches only rejectUnauthorized: false and a NODE_TLS_REJECT_UNAUTHORIZED = 0 assignment, so the wrong implementation's checkServerIdentity override passes, and its metadata files the finding under CWE-319 rather than CWE-295. It is not in p/security-audit, which found nothing on this card.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/problem-based-packs.insecure-transport.js-node.bypass-tls-verification.bypass-tls-verification node-express (rule text read at semgrep-rules 9bbf021)
CodeQLjs/disabling-certificate-validationFrom the query source: it reports a false rejectUnauthorized in options that reach https.Agent, an HTTPS client request, tls.connect or tls.createServer, and NODE_TLS_REJECT_UNAUTHORIZED set to '0'. That covers vulnerable.js. It has no case for checkServerIdentity, so wrong.js is outside it.Checked: github/codeql main at b5b8165; not executed
Node.js runtimen/aProcess warning for NODE_TLS_REJECT_UNAUTHORIZEDNode prints one warning when NODE_TLS_REJECT_UNAUTHORIZED=0 is set (it appears in this card's environment test) and nothing for rejectUnauthorized: false or a checkServerIdentity override in code.Checked: Node.js 26.11.1

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
node:https, node:tls (built in)Node.js 26.11.1
node:crypto (test PKI, built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the tls-certificate-validation/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs tls-certificate-validation/node-express runs all three modes and checks the expected results.

Download the project ZIP, 29 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
Matrix
fixed: 6 of 6 tests pass; vulnerable: 5 of 6 tests fail (5 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
Test results
fixed: 6/6 pass; vulnerable: 5/6 fail (5 exploit); wrong: 2/6 fail (2 exploit)
Dependency freshness
express 5.3.0 was published on 2026-10-09 and is taken at one day old because it is a security release (qs, body-parser and proxy-addr advisories). npm audit found 0 vulnerabilities and npm audit signatures verified all 65 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Notes
The test PKI is minted with node:crypto and a small DER encoder in test/pki.js, so npm ci installs only Express; OpenSSL 3.6.4 accepted the partner certificate under -x509_strict. The wrong implementation fails the host name test and the environment test, because it also relies on the default rejectUnauthorized.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Node.js v26: tls.connect options (rejectUnauthorized, checkServerIdentity, servername)
  2. Node.js v26: tls.createSecureContext ca (replaces the default CAs)
  3. Node.js v26: tls.getCACertificates
  4. Node.js v26: NODE_TLS_REJECT_UNAUTHORIZED
  5. Node.js v26: NODE_EXTRA_CA_CERTS
  6. Node.js v26: https.Agent
  7. OWASP Transport Layer Security Cheat Sheet
  8. CWE-295: Improper Certificate Validation
  9. CWE-297: Improper Validation of Certificate with Host Mismatch

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.