open atlas
↑ Back to track
Node.js, zero to senior NODE · 11 · 01

TLS and HTTPS in Node: ship the whole chain

TLS in Node hinges on shipping the full certificate chain, not just the leaf. Learn the handshake, SNI, session resumption, secureContext, and the cert errors you actually hit — and why you fix the chain instead of disabling verification.

NODE Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

The certificate works in Chrome, so you ship. Then a backend service starts throwing UNABLE_TO_VERIFY_LEAF_SIGNATURE on every outbound fetch to your own API, and curl fails too. The cert isn’t expired, the hostname matches, the private key is right. The bug: your server presents only the leaf certificate. Browsers paper over this — they cache intermediates from previous sites and fill the gap (AIA fetching). Node does not. It has only the system root store and whatever you sent on the wire, and the issuing intermediate is missing, so the chain to a trusted root cannot be built. The fix is one line in your cert file, not a flag that turns verification off.

A TLS server in Node, and what it actually serves

https.createServer is a thin wrapper over tls.createServer: you hand it a private key and a certificate, it terminates TLS, and your request handler runs over the decrypted stream. The single most consequential field is cert — and it must contain the full chain, not just your server’s own (leaf) certificate.

import { readFileSync } from "node:fs";
import { createServer } from "node:https";

const server = createServer(
  {
    key: readFileSync("privkey.pem"),
    // fullchain.pem = leaf certificate FOLLOWED BY every intermediate, in order.
    // This is the #1 production TLS bug: shipping cert.pem (leaf only) instead.
    cert: readFileSync("fullchain.pem"),
  },
  (req, res) => {
    res.writeHead(200);
    res.end("hello over TLS\n");
  },
);
server.listen(443);

A certificate is an X.509 document binding a public key to a set of names (the Subject Alternative Names, or SANs) and signed by an issuer. Your leaf is signed by an intermediate CA, which is signed by a root CA that lives in the client’s trust store. Verification is the client walking that chain upward until it reaches a root it already trusts. The client only inherently trusts roots — it does not have your intermediate. So if your server sends only the leaf, the client cannot connect leaf → root, and verification fails. Let’s Encrypt’s fullchain.pem exists precisely to solve this: it is the leaf concatenated with the intermediate(s). Use it, not cert.pem.

The handshake: how a key gets agreed without leaking

Before any HTTP byte flows, client and server run the TLS handshake: agree on parameters, prove the server’s identity with its certificate, and derive a shared symmetric key. TLS 1.3 streamlined this into a single round trip (1-RTT) because the client guesses the key-exchange group and sends its key share in the very first message, so the server can reply with everything needed to start encrypting.

The client verifies the certificate chain (signatures valid up to a trusted root, not expired, and the requested hostname present in the leaf’s SANs), checks CertificateVerify to prove the server holds the matching private key, and then both sides switch to symmetric encryption. The asymmetric crypto authenticates and exchanges the key; the bulk of the traffic is fast symmetric (AES-GCM or ChaCha20). TLS 1.2 needed two round trips; if you can require 1.3, the handshake is meaningfully cheaper.

SNI: many certs on one socket

A single IP and port often serve many hostnames. The client tells the server which hostname it wants in the SNI (Server Name Indication) extension of the ClientHello — in plaintext, before the certificate is chosen — so the server can present the right certificate. In Node you select per-name certs with SNICallback, returning a SecureContext built per hostname:

import tls from "node:tls";
import { readFileSync } from "node:fs";
import { createServer } from "node:https";

const contexts = {
  "a.example.com": tls.createSecureContext({
    key: readFileSync("a.key"), cert: readFileSync("a.fullchain.pem"),
  }),
  "b.example.com": tls.createSecureContext({
    key: readFileSync("b.key"), cert: readFileSync("b.fullchain.pem"),
  }),
};

createServer({
  SNICallback(servername, cb) {
    const ctx = contexts[servername];
    cb(ctx ? null : new Error("unknown host"), ctx);
  },
}, handler).listen(443);

tls.createSecureContext is the reusable bundle of key, chain, ciphers, and protocol settings; SNICallback lets you pick one at handshake time. This is how a reverse proxy or multi-tenant server hosts dozens of domains on one listener.

Session resumption: skip the expensive part

The full handshake costs asymmetric crypto and a round trip. Resumption lets a returning client reuse the previously negotiated secret and skip most of that. Two mechanisms: session IDs, where the server stores session state keyed by an ID and the client presents the ID to resume (server-side memory, doesn’t scale across a cluster without shared storage); and session tickets (RFC 5077), where the server encrypts the session state into an opaque blob the client stores and replays — stateless on the server, which is why it scales horizontally. In TLS 1.3, resumption uses pre-shared keys derived from tickets and can even carry 0-RTT early data. Resumption turns a multi-message, asymmetric handshake into a fast abbreviated one, cutting connection latency for repeat visitors.

Why this works

The senior reflex when you hit a cert error is never rejectUnauthorized: false (or NODE_TLS_REJECT_UNAUTHORIZED=0). That doesn’t fix anything — it disables the entire point of TLS authentication, leaving the connection wide open to a man-in-the-middle who can present any certificate they like. Encryption without verified identity is encryption to an attacker. Every cert error has a real, fixable cause: a missing intermediate (fix the chain), an internal CA the client doesn’t know (add it to the client’s trust via ca: or NODE_EXTRA_CA_CERTS), an expired cert (renew it), or a hostname mismatch (reissue with the right SAN). Disabling verification just hides the bug until it bites in production.

The cert errors you actually hit

These four account for most real TLS support tickets. The constant tells you the cause:

ErrorReal causeFix
UNABLE_TO_VERIFY_LEAF_SIGNATUREServer sent the leaf but omitted the intermediate; the client can’t build a path to a root.Serve fullchain.pem (leaf + intermediates), not cert.pem.
DEPTH_ZERO_SELF_SIGNED_CERTA self-signed cert with no chain to any trusted CA (common in dev / internal services).Add the cert/CA to the client’s trust: ca: option or NODE_EXTRA_CA_CERTS. Never disable verification.
CERT_HAS_EXPIREDThe leaf (or an intermediate) is past notAfter, or the client clock is wrong.Renew/automate the cert; check NTP on the client.
ERR_TLS_CERT_ALTNAME_INVALIDThe hostname you connected to isn’t in the cert’s SAN list (CN is ignored by modern clients).Reissue the cert with the correct SAN; connect by a name actually in the cert.

Notice none of the fixes is “turn off checking.” Each error is the verification machinery correctly reporting a real defect in the chain, validity window, or naming. When you see one of these constants in a log, read it as a precise diagnosis, not as a prompt to disable verification — the constant tells you exactly what to fix.

Quiz

Your cert loads fine in a browser but Node fetch throws UNABLE_TO_VERIFY_LEAF_SIGNATURE against the same server. Most likely cause?

Pick the best fit

A Node client connects to an internal service whose cert is signed by your company's private CA, and it fails verification. Pick the correct fix.

Recall before you leave
  1. 01
    Why does a cert that works in the browser throw UNABLE_TO_VERIFY_LEAF_SIGNATURE in Node, and how do you fix it?
  2. 02
    Contrast session IDs and session tickets for TLS resumption, and say why tickets scale better.
Recap

TLS in Node starts with https.createServer (a wrapper over tls.createServer) taking a key and a certificate — and that certificate must be the full chain, leaf plus intermediates, because clients trust only roots and must walk the chain upward to one. Serving the leaf alone is the classic production bug behind UNABLE_TO_VERIFY_LEAF_SIGNATURE; ship fullchain.pem. The handshake exchanges a key and authenticates the server with that chain, and TLS 1.3 does it in one round trip by sending the client’s key share up front. SNI lets one socket serve many hostnames, with SNICallback returning a per-host SecureContext built by tls.createSecureContext. Session resumption — server-stored session IDs, or stateless client-held session tickets that scale across a cluster — skips the costly part of the handshake for repeat clients. And every cert error (DEPTH_ZERO_SELF_SIGNED_CERT, CERT_HAS_EXPIRED, ERR_TLS_CERT_ALTNAME_INVALID) names a real defect: fix the chain, the trust store, the expiry, or the SAN. Never reach for rejectUnauthorized: false — encryption without verified identity is encryption straight to an attacker. Now when you see an UNABLE_TO_VERIFY_LEAF_SIGNATURE in production, your first question is “did we ship fullchain.pem?” — not “how do we disable the check?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.