unable to verify the first certificate in Node.js: what it means and how to fix it
Your Node app calls an HTTPS endpoint and gets this back:
Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
The site opens fine in your browser, so it is tempting to set NODE_TLS_REJECT_UNAUTHORIZED=0 and move on. Don’t. This error usually means the server is misconfigured and Node is the first client strict enough to notice. The hint in the message is also wrong for the most common cause.
Read the error code first
With fetch, the code is on err.cause.code. With https.request, it is on err.code. These are the messages Node 26 gave us against test hosts:
| Code | Message | What it usually means |
|---|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE | unable to verify the first certificate | The server does not send its intermediate |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | unable to get local issuer certificate | A private CA or TLS-inspecting proxy |
SELF_SIGNED_CERT_IN_CHAIN | self-signed certificate in certificate chain | Same, with the untrusted root sent in the chain |
DEPTH_ZERO_SELF_SIGNED_CERT | self-signed certificate | A self-signed server certificate |
To see what the server sends, count the BEGIN CERTIFICATE blocks here. A public site that sends only one has a broken chain:
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
The first certificate is fine, the chain is broken
Node received the server’s certificate but not the intermediate that signed it, so it cannot connect it to a trusted root. Browsers hide the problem by downloading the missing intermediate themselves. Node never does, on any OS. Here is incomplete-chain.badssl.com, which sends only the leaf:
Chromium: loads normally
node 26, macOS (fetch): UNABLE_TO_VERIFY_LEAF_SIGNATURE
node 24, Linux (fetch): UNABLE_TO_VERIFY_LEAF_SIGNATURE
node --use-system-ca (fetch): UNABLE_TO_VERIFY_LEAF_SIGNATURE
Note the last line. The error message suggests --use-system-ca, but that flag only changes which roots Node trusts, so it cannot fill in a missing intermediate.
The fix belongs on the server. With Let’s Encrypt, serve fullchain.pem, never cert.pem:
import https from "node:https";
import { readFileSync } from "node:fs";
https
.createServer(
{
key: readFileSync("/etc/letsencrypt/live/example.com/privkey.pem"),
// fullchain.pem = certificate + intermediate. cert.pem alone causes this error.
cert: readFileSync("/etc/letsencrypt/live/example.com/fullchain.pem"),
},
handler,
)
.listen(443);
We tested both files: cert.pem fails, fullchain.pem returns 200. The same applies to nginx, load balancers, and every cloud “upload your certificate” form. If there is a chain field, fill it in.
If you don’t run the server, report it. Trusting the intermediate on your side works only until they renew with a different one. Go clients fail on the same servers, see x509: certificate signed by unknown authority in Go.
Private CAs and corporate proxies
UNABLE_TO_GET_ISSUER_CERT_LOCALLY and SELF_SIGNED_CERT_IN_CHAIN mean the chain ends at a root Node does not trust. You need to add that root, and there are four ways to do it.
NODE_EXTRA_CA_CERTS is the right default. It adds to Node’s bundled roots and works for fetch, https, and most libraries:
NODE_EXTRA_CA_CERTS=/etc/myapp/internal-ca.pem node server.js
Node reads it once at startup, so setting it from process.env in your code does nothing. A missing or bad file only produces a warning.
--use-system-ca (or NODE_USE_SYSTEM_CA=1) trusts the OS store as well as Node’s bundle. If IT already installs the proxy root on every machine, this picks it up. It needs Node 23.8+, or 23.9+ on Linux.
The ca option works per agent or per request, and it has a trap: it replaces Node’s roots instead of adding to them. We tested { ca: internalCA } alone. The internal host worked and https://sslboard.com broke. Include the public roots:
import https from "node:https";
import tls from "node:tls";
import { readFileSync } from "node:fs";
const internalCA = readFileSync("/etc/myapp/internal-ca.pem", "utf8");
const agent = new https.Agent({ ca: [...tls.rootCertificates, internalCA] });
tls.setDefaultCACertificates covers fetch, which has no ca option. It needs Node 24.5+ or 22.19+, also replaces the list, and should run once at startup:
tls.setDefaultCACertificates([...tls.getCACertificates("default"), internalCA]);
For self-signed certificates in development, mkcert creates a local CA you can load with NODE_EXTRA_CA_CERTS.
Why not NODE_TLS_REJECT_UNAUTHORIZED=0?
Node itself warns you every time:
Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.
It is process-wide. It turns off verification for every connection your app makes, including the payment API and the database, so anyone in the network path can impersonate any of them. It also tends to stay in the Dockerfile long after the outage that put it there. Trust the specific CA instead.
Find incomplete chains before your clients do
A missing intermediate passes every browser check, so the first report usually comes from someone’s Node, Go, or Java integration.
SSLBoard finds every hostname under your domain through Certificate Transparency logs, connects to each one, and flags certificate errors such as incomplete chains. Run a free scan and check the certificate errors section. Any host listed there fails fetch in Node, whatever your browser says.