Test Node.js TLS hostname verification before deploying a client

John Burns

A TLS client can trust the certificate authority that signed a certificate and still need to reject the connection. Trust answers who signed the certificate; hostname verification answers whether that certificate was issued for the service the client intended to reach. Losing the second check turns a certificate for one trusted name into a possible credential for another connection.

This matters when a Node.js service uses a private CA, a development proxy, a custom TLS wrapper, or an extra callback around tls.connect(). The June 2026 Node.js security releases included a hostname-verification issue, tracked as CVE-2026-48934. The immediate operational response is to update to a supported patched release, then test the client configuration that will actually ship. A version check does not show whether application code has changed the connection options or replaced the default identity check.

This guide builds a self-contained test with a temporary certificate and a loopback-only TLS server. The validation run used Node.js v22.23.2 and OpenSSL 3.5.6 on Linux AMD64. A client that trusted the test certificate connected successfully when its servername was api.example.test. The same client, with the same CA and server, failed with ERR_TLS_CERT_ALTNAME_INVALID when asked to verify other.example.test. That paired result is the useful gate: it proves both the expected successful path and the hostname-rejection path before a client reaches a real service.

Keep the three TLS decisions separate

A typical client needs to make three distinct decisions:

  1. Can it establish an encrypted TLS connection?
  2. Does the certificate chain lead to a CA that this client trusts?
  3. Does the certificate identify the hostname the client intended to contact?

A successful handshake alone does not answer all three questions. For example, setting rejectUnauthorized: false permits a client to continue after certificate validation errors. That may be useful for an isolated diagnostic, but it is not an acceptable production workaround for an untrusted certificate or a hostname mismatch. Remove it rather than making it an environment-dependent default.

The hostname used for verification is servername in Node’s TLS connection options. It also supplies Server Name Indication (SNI) to a server that hosts more than one certificate. Do not assume it will be inferred correctly from a load-balancer address, a localhost test address, or an internal connection target. Set it deliberately to the DNS name that appears in the service certificate’s Subject Alternative Name (SAN).

The SAN is the important field. Modern clients use SAN values for DNS and IP identity checks; a certificate’s common name is not a substitute for an appropriate SAN. Check the certificate a deployment will use before changing the client:

openssl x509 -in service.crt -noout -subject -ext subjectAltName

The output should include the exact DNS name expected by the client, such as DNS:api.example.test. Do not publish real internal hostnames, certificate files, private keys, or CA material in a ticket or a source repository. Keep the check and its output inside the approved change record.

Create a disposable certificate for the test

Run the following in an empty temporary directory. It creates a one-day self-signed certificate only for a local test. The -nodes option leaves its generated private key unencrypted, so never reuse this pattern for a real service key and remove the directory after the test.

workdir=$(mktemp -d)
cd "$workdir"

openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
  -keyout server.key \
  -out server.crt \
  -subj '/CN=api.example.test' \
  -addext 'subjectAltName=DNS:api.example.test'

openssl x509 -in server.crt -noout -subject -ext subjectAltName

The final command should show the api.example.test SAN. The test uses the certificate itself as the client CA so that chain trust succeeds. That is intentional for a disposable self-signed fixture. A deployment should instead use the organization-approved CA bundle or the system trust store, with its source and update process documented.

Test the matching and mismatching names

Save the following as tls-hostname-test.cjs in the same directory. It starts a TLS server on the IPv4 loopback address and chooses an available ephemeral port. The listener is local to the test process; it does not expose a port on the network.

const fs = require('node:fs');
const tls = require('node:tls');

const serverOptions = {
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.crt'),
};
const ca = fs.readFileSync('server.crt');

function connect(port, servername, label) {
  return new Promise((resolve) => {
    const socket = tls.connect({
      host: 'localhost',
      port,
      servername,
      ca,
      rejectUnauthorized: true,
    });
    socket.once('secureConnect', () => {
      console.log(`${label}: secureConnect authorized=${socket.authorized}`);
      socket.end();
      resolve(0);
    });
    socket.once('error', (error) => {
      console.log(`${label}: error code=${error.code}`);
      resolve(1);
    });
  });
}

const server = tls.createServer(serverOptions, (socket) => socket.end());
server.listen(0, 'localhost', async () => {
  const { port } = server.address();
  const valid = await connect(port, 'api.example.test', 'matching-name');
  const mismatch = await connect(port, 'other.example.test', 'wrong-name');
  server.close(() => {
    if (valid !== 0 || mismatch !== 1) process.exitCode = 1;
  });
});

Run it with the same Node.js major and minor release that the application will use:

node --version
node tls-hostname-test.cjs

In the validation run, the matching name established an authorized connection, while the mismatching name failed before an authorized connection was available:

matching-name: secureConnect authorized=true
wrong-name: error code=ERR_TLS_CERT_ALTNAME_INVALID

The process must exit zero only when those two outcomes occur. A matching name that fails usually means the test CA was not supplied, the certificate lacks the intended SAN, or the local server did not load the expected file. A mismatching name that connects is a stop condition: inspect the connection options and any wrapper code before deploying.

Apply the test to a real client without weakening it

A production client normally needs an explicit servername only when its network address differs from its DNS identity, such as a direct connection to a load balancer, a service-mesh sidecar, or a test endpoint. Keep rejectUnauthorized: true, provide only the required CA material, and use the intended service DNS name:

const tls = require('node:tls');

const socket = tls.connect({
  host: '<connection-address>',
  port: 443,
  servername: 'api.example.test',
  ca: process.env.NODE_EXTRA_CA_CERTS
    ? undefined
    : require('node:fs').readFileSync('<approved-ca-file>'),
  rejectUnauthorized: true,
});

The CA example has two mutually exclusive operating models. If the runtime is started with NODE_EXTRA_CA_CERTS, let Node load that approved file once at process startup and omit the per-connection ca option. Otherwise, read a narrowly scoped approved CA file. Do not set NODE_TLS_REJECT_UNAUTHORIZED=0; Node documents that setting as disabling TLS certificate validation, and it can affect connections beyond the one being diagnosed.

Avoid a custom checkServerIdentity callback unless the application has a documented identity rule that the default check cannot express. A callback can replace the default hostname verification. If one is necessary, call tls.checkServerIdentity(hostname, cert) first, return its error unchanged, and add only the narrowly required additional rule after the default check succeeds. Put a matching-name and wrong-name case in the application’s automated tests whenever this callback changes.

Make the negative case part of the release gate

The local fixture tests Node’s TLS behavior, not a live service’s certificate rotation or load-balancer configuration. Before release, run the same two-case pattern against a temporary certificate that represents the intended service name, then perform an authorized integration test against the real endpoint. Record the Node version, the expected DNS name, the certificate chain source, and the result without copying certificate contents or internal addressing into public logs.

The CVE makes patching important, but patching and configuration verification are complementary. Update the runtime, preserve normal certificate and hostname verification, and keep a small test that proves a trusted certificate for the right name succeeds while the same certificate for the wrong name fails.

Sources