Programming languages

Node.js UNABLE_TO_VERIFY_LEAF_SIGNATURE: fix the chain

By · Last verified

Error messageError: unable to verify the first certificate (code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')

unable to verify the first certificate means Node.js received the server's certificate but could not find the CA certificate that signed it, so verification fails at the first step. Usually the server is not sending its intermediate certificate.

Quick fix

A single certificate in the output confirms the missing intermediate.

Check the chain the server sends:

openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null | grep -E "^ *[0-9] s:|Verify return"

If you see only 0 s: and Verify return code: 21 (unable to verify the first certificate), fix the server: serve the leaf plus intermediates (Nginx ssl_certificate fullchain.pem;). The chain builder produces the right file.

If you cannot change the server, give Node.js the missing intermediate or private root at startup:

export NODE_EXTRA_CA_CERTS=/etc/ssl/extra/example-intermediate.pem
node app.js

Causes

A server that omits its intermediate certificate is the usual cause.

1. The server omits the intermediate certificate

  • Confirm: the openssl s_client output above lists a single certificate; the SSL checker reports an incomplete chain.
  • Fix: install the full chain. For Let's Encrypt use fullchain.pem; for commercial CAs concatenate the leaf and the CA bundle the CA provides (leaf first). See missing intermediate certificate.

2. Wrong order or wrong intermediate

Some servers send intermediates from an older chain, or in the wrong order.

  • Confirm: the issuer of certificate 0 does not match the subject of certificate 1.
  • Fix: rebuild the bundle from the CA's current intermediate.

3. Your code passes its own ca list

When you set the ca option on https.Agent or tls.connect, Node.js uses only that list instead of its default root store.

  • Confirm: search the code for ca: or a custom Agent.
  • Fix: include every needed root in that list, or use NODE_EXTRA_CA_CERTS so that the defaults stay in place.

4. TLS-inspecting proxy that sends only its leaf

Some proxies generate a leaf certificate signed by an intermediate they never send.

  • Fix: get the proxy's full CA chain from IT and add it with NODE_EXTRA_CA_CERTS.

Fixes by environment

Fix the server chain where you can; on clients, add the issuing CA through the ca option or NODE_EXTRA_CA_CERTS.

Per request (code you own):

const fs = require("node:fs");
const https = require("node:https");
const tls = require("node:tls");

const agent = new https.Agent({
  ca: [...tls.rootCertificates, fs.readFileSync("/etc/ssl/extra/company-root.pem")],
});
https.get("https://api.example.com/", { agent }, (res) => console.log(res.statusCode));

Using the operating system store: node --use-openssl-ca makes Node.js use OpenSSL's default CA store (the system bundle on most Linux distributions) instead of its bundled list. Recent Node.js releases also offer --use-system-ca; check node --help for your version.

Docker: add the certificate to the image and point the variable at it:

COPY company-root.pem /usr/local/share/ca-certificates/company-root.crt
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/company-root.crt

Electron and serverless: set NODE_EXTRA_CA_CERTS in the launch environment or function configuration; it cannot be set from application code after start-up.

npm and yarn run on Node.js and show the same family of errors; see npm unable to get local issuer certificate.

Don't do this

  • NODE_TLS_REJECT_UNAUTHORIZED=0. It disables certificate verification for every TLS connection in the process, including calls to payment and auth APIs. Node.js prints a warning for a reason.
  • rejectUnauthorized: false in a shared HTTP client. It quietly spreads to every caller of that client.
  • Pinning the leaf certificate as a "CA". It works until the next renewal, which with short certificate lifetimes is weeks away.

FAQ

Why does the URL open in Chrome but fail in Node.js?

Chrome fetches a missing intermediate from the URL in the certificate's Authority Information Access extension and caches intermediates it has seen. Node.js uses OpenSSL verification without that repair, so a server with an incomplete chain fails only in Node.js, curl, Python and similar clients.

What is the difference from UNABLE_TO_GET_ISSUER_CERT_LOCALLY?

UNABLE_TO_VERIFY_LEAF_SIGNATURE ("unable to verify the first certificate") is reported when the chain stops right after the leaf. UNABLE_TO_GET_ISSUER_CERT_LOCALLY means a longer chain was built but its top certificate is not trusted locally, which points more often to a proxy or private CA.

Does NODE_EXTRA_CA_CERTS replace the built-in CAs?

No. It adds the certificates in the file to Node's bundled Mozilla CA list. It is read once at process start, so set it before launching Node.js, not from inside your code.

Is it OK to set rejectUnauthorized: false for a single request?

It is safer than the global environment variable but still lets anyone on the path impersonate that server. Pass the right ca for that request instead; it is one line of code.

Sources

  1. Node.js TLS (SSL) documentation
  2. Node.js command-line options (NODE_EXTRA_CA_CERTS)
  3. OpenSSL X509_STORE_CTX_get_error (verification error codes)