UNABLE_TO_VERIFY_LEAF_SIGNATURE
Node.js raises "unable to verify the first certificate", error code UNABLE_TO_VERIFY_LEAF_SIGNATURE, when the server presented its own certificate but not the intermediate CA certificates needed to link it to a trusted root. Node ships its own copy of the Mozilla root store and, unlike a browser, never fetches a missing intermediate over the Authority Information Access extension, so a chain a browser silently repairs fails outright under Node. The correct fix is on the server, which must serve the full chain leaf-first; where that server is not yours, supply the missing issuer to Node with `NODE_EXTRA_CA_CERTS=/path/to/intermediate.pem`, which adds to the bundled roots rather than replacing them.
What the error actually means #
“The first certificate” is the one at the bottom of the chain — the server’s own. Node could not verify its signature, because it does not have the certificate of whoever signed it.
That is almost always because the server did not send it. A TLS server is required to present its own certificate and every intermediate needed to reach a trusted root; sending only the leaf is the single most common TLS misconfiguration on the internet.
Which Node TLS error do you have? #
Node’s codes are precise, and each points at a different problem. Read yours out of the error object before doing anything else:
| Code | Meaning | Where to go |
|---|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE |
Server sent no intermediates | This page |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY |
Issuer missing from Node’s store | unable to get local issuer certificate |
SELF_SIGNED_CERT_IN_CHAIN |
Chain ends in an untrusted self-signed CA | self signed certificate in chain |
DEPTH_ZERO_SELF_SIGNED_CERT |
The server certificate signed itself | self-signed vs CA-signed |
CERT_HAS_EXPIRED |
Validity dates have passed | certificate has expired |
ERR_TLS_CERT_ALTNAME_INVALID |
Hostname not in the SAN list | SAN explained |
Step 1 — confirm the chain is short #
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE" Expected output
1
A single certificate from a publicly trusted CA confirms the diagnosis. Two or more, and the chain is complete — in which case you have a different error code, and the table above is the right place to look.
Step 2 — fix the server, if it is yours #
Concatenate the certificate and its intermediates into one file, leaf first, walking upward:
cat certificate.crt chain.crt > fullchain.crt Prove it validates before deploying it. -untrusted supplies the intermediates that the chain
needs but that are not themselves trust anchors:
openssl verify -CAfile root-ca.crt -untrusted chain.crt certificate.crt Expected output
certificate.crt: OK
Then point the server at fullchain.crt rather than at the bare certificate — nginx in particular
has no separate chain directive, so ssl_certificate must name the combined file. The per-server
configuration is on install an SSL certificate on nginx.
Step 3 — where the server is not yours #
Supply the missing issuer to Node directly. Save the intermediate as PEM and name it:
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/intermediate.pem
node app.js For a single request, pass the certificate in code instead of changing the environment:
import { readFileSync } from 'node:fs';
import https from 'node:https';
const agent = new https.Agent({
ca: readFileSync('/etc/ssl/certs/intermediate.pem'),
});
https.get('https://example.com/', { agent }, (res) => {
console.log(res.statusCode);
}); Or take the OpenSSL system store instead of Node’s bundled list, which is the right choice on a machine where a corporate root is already installed:
node --use-openssl-ca app.js npm, yarn and pnpm #
The package managers use the same TLS stack and surface the same code. Point them at a bundle containing the missing issuer:
| Tool | Setting |
|---|---|
| npm | npm config set cafile /path/to/ca-bundle.pem |
| yarn | yarn config set cafile /path/to/ca-bundle.pem |
| pnpm | pnpm config set cafile /path/to/ca-bundle.pem |
| All three | NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.pem |
What not to do #
Frequently asked questions
- What is the difference between this and SELF_SIGNED_CERT_IN_CHAIN?
- This error means a link is missing — Node found no issuer for the certificate it was given.
SELF_SIGNED_CERT_IN_CHAINmeans the chain was complete but terminated in a self-signed certificate Node does not trust, which is what a corporate TLS-inspecting proxy looks like. - Does NODE_EXTRA_CA_CERTS replace the built-in roots?
- No, it appends to them, which is the opposite of Java's
javax.net.ssl.trustStore. Public sites keep working after you set it. Node reads the file once during startup, so the process must be restarted, and a path that does not exist is ignored with only a warning. - Why does the same URL work in curl and the browser?
- They have different tolerances. A browser caches intermediates from earlier visits and fetches missing ones over the Authority Information Access extension; curl uses the system bundle, which on many machines already contains the intermediate. Node does neither, so it is often the first thing to notice a genuinely broken chain.
- How do I fix this for npm specifically?
- Point npm at a bundle containing the missing issuer with
npm config set cafile /path/to/ca-bundle.pem. Do not usenpm config set strict-ssl false, which disables verification for every package you install. - Can I use the system trust store instead of Node's bundled one?
- Yes. Start Node with
--use-openssl-ca, and it reads the OpenSSL default store rather than its compiled-in Mozilla list. This is useful on a machine where a corporate root is already installed system-wide.
References
- Node.js TLS: NODE_EXTRA_CA_CERTS and rejectUnauthorized — how Node assembles its trust store and what the tls options override
- openssl-verify(1) — the -untrusted flag used to prove a chain validates with the intermediate supplied