DEV Community

Timothy Kelvin
Timothy Kelvin

Posted on

Untrusted certificate? Telling apart three different TLS problems in Node

When a TLS check fails, a lot of tools stop at "certificate not trusted". That's true, but it doesn't tell anyone what to fix, and the fix is different depending on the cause:

  1. The certificate is self-signed. Nobody vouches for it. The fix is a certificate from a real CA.
  2. The server isn't sending its intermediate certificate. The certificate itself is fine. The fix is in the server config: serve the full chain, not just the leaf.
  3. The chain ends at a root the client doesn't trust. Either it's a private CA, or it's a public root that has since been retired. The fix is on the client side, or a certificate that chains to a current root.

I wanted a domain check I maintain to say which of these it is. Here's what Node actually gives you, tested against the deliberately broken hosts at badssl.com (Node v24).

Reading the certificate anyway

To inspect a bad certificate you have to connect without rejecting it:

const socket = tls.connect({ host, port: 443, servername: host, rejectUnauthorized: false }, () => {
  console.log(socket.authorized, socket.authorizationError);
});
Enter fullscreen mode Exit fullscreen mode

This is for inspection only. You get the certificate and the trust verdict, and you don't send anything over the connection.

Gotcha: authorizationError is a string

My own code had socket.authorizationError?.message, which is always undefined, so the error field in my output was quietly null for every broken certificate. In practice authorizationError is a plain code string:

Host authorizationError
self-signed.badssl.com DEPTH_ZERO_SELF_SIGNED_CERT
incomplete-chain.badssl.com UNABLE_TO_VERIFY_LEAF_SIGNATURE
untrusted-root.badssl.com SELF_SIGNED_CERT_IN_CHAIN
expired.badssl.com CERT_HAS_EXPIRED
wrong.host.badssl.com ERR_TLS_CERT_ALTNAME_INVALID

Why the code alone isn't enough

Node reports one problem, the first it runs into. expired.badssl.com is a good example. Its certificate expired in 2015, and the chain behind it ends at AddTrust External CA Root, which expired in 2020 and isn't among the 120 roots bundled with Node. The code says CERT_HAS_EXPIRED. Renewing the certificate is the right first step, but it's not the whole story.

So besides the code, I look at the chain itself.

Walking the chain

getPeerCertificate(true) returns the leaf with issuerCertificate links to each issuer Node could find. A root, or any self-signed certificate, points to itself. If an intermediate is missing, the links simply stop.

import tls from 'node:tls';
import crypto from 'node:crypto';

const bundledRoots = new Set(
  tls.rootCertificates.map((pem) => new crypto.X509Certificate(pem).fingerprint256),
);

function diagnose(socket) {
  if (socket.authorized) return 'trusted';
  const leaf = socket.getPeerCertificate(true);
  if (leaf.issuerCertificate === leaf) return 'self-signed-leaf';

  const chain = [];
  let cert = leaf;
  while (cert && !chain.includes(cert)) {
    chain.push(cert);
    if (!cert.issuerCertificate || cert.issuerCertificate === cert) break;
    cert = cert.issuerCertificate;
  }
  const last = chain[chain.length - 1];
  if (last.issuerCertificate !== last) return 'chain-incomplete';
  return bundledRoots.has(last.fingerprint256) ? 'chain-ok' : 'unknown-root';
}
Enter fullscreen mode Exit fullscreen mode

The chain.includes check stops the loop at the self-reference. Comparing the last certificate's fingerprint with tls.rootCertificates tells you whether the chain ends at a root Node ships with.

If the chain is fine but the socket still isn't authorized, check the other two usual suspects directly: valid_to for expiry, and tls.checkServerIdentity(host, cert), which returns an error when the name doesn't match.

Results

Host Node's code Chain Other checks
badssl.com none trusted
self-signed.badssl.com DEPTH_ZERO_SELF_SIGNED_CERT self-signed-leaf
incomplete-chain.badssl.com UNABLE_TO_VERIFY_LEAF_SIGNATURE chain-incomplete
untrusted-root.badssl.com SELF_SIGNED_CERT_IN_CHAIN unknown-root
expired.badssl.com CERT_HAS_EXPIRED unknown-root expired
wrong.host.badssl.com ERR_TLS_CERT_ALTNAME_INVALID chain-ok name mismatch

Each row maps to a different fix:

  • self-signed-leaf: fine for internal testing, otherwise replace it with a CA-issued certificate.
  • chain-incomplete: the certificate is probably fine and the server config isn't. Browsers can often fill in a missing intermediate on their own, so a site can look fine in a browser and still fail for API clients and scripts.
  • unknown-root: a private CA, or a retired public root. With a private CA, the clients need its root. With a retired root, the server needs a certificate that chains to a current one.
  • chain-ok but not authorized: look at the expiry date and the hostname.

One caveat: the chain you get back is the one Node could build, from what the server sent plus its own root store. On badssl.com it ends at ISRG Root X1, which comes from Node's store, not from the server. That's fine for diagnosis, but it means the result describes what this particular client can verify.

Top comments (0)