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:
- The certificate is self-signed. Nobody vouches for it. The fix is a certificate from a real CA.
- 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.
- 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:
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.comDEPTH_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.