# 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.

Source: https://certguru.io/errors/unable-to-verify-leaf-signature/
Last updated: 2026-08-19
Commands verified against OpenSSL 3.5.7.

---

> **The exact message**
>
> ```
>   Error: unable to verify the first certificate
>       at TLSSocket.onConnectSecure (node:_tls_wrap:1674:34)
>       code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
>   ```
>   npm reports the same condition as `npm ERR! code UNABLE_TO_VERIFY_LEAF_SIGNATURE`.

## 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.

> **Node is stricter than your browser, not broken**
>
> Browsers repair this quietly. They cache intermediates seen on previous visits and follow the
>   Authority Information Access extension to download a missing one mid-handshake. Node does neither.
>   A site that loads in Chrome and fails in Node is genuinely misconfigured — Chrome is hiding it.

## 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](/errors/unable-to-get-local-issuer-certificate/) |
| `SELF_SIGNED_CERT_IN_CHAIN` | Chain ends in an untrusted self-signed CA | [self signed certificate in chain](/errors/self-signed-certificate-in-chain/) |
| `DEPTH_ZERO_SELF_SIGNED_CERT` | The server certificate signed itself | [self-signed vs CA-signed](/concepts/self-signed-vs-ca-signed/) |
| `CERT_HAS_EXPIRED` | Validity dates have passed | [certificate has expired](/errors/certificate-has-expired/) |
| `ERR_TLS_CERT_ALTNAME_INVALID` | Hostname not in the SAN list | [SAN explained](/concepts/san/) |

## Step 1 — confirm the chain is short

*Count the certificates the server offers*

```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts /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:

*Build a correctly ordered chain*

```bash
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:

*Verify the repaired chain*

```bash
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](/platforms/nginx/).

## Step 3 — where the server is not yours

Supply the missing issuer to Node directly. Save the intermediate as PEM and name it:

```bash
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/intermediate.pem
node app.js
```

> **This appends, it does not replace**
>
> `NODE_EXTRA_CA_CERTS` adds to Node's bundled Mozilla roots, so public sites keep working. This is
>   the opposite of Java, where setting a truststore replaces the default one entirely. The file is
>   read once at startup, so restart the process, and check for a warning on boot — Node ignores an
>   unreadable path rather than failing.

For a single request, pass the certificate in code instead of changing the environment:

*Per-request trust, verification intact*

```js
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:

```bash
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

> **rejectUnauthorized: false and NODE_TLS_REJECT_UNAUTHORIZED=0**
>
> Both remove certificate verification. The environment variable is the more dangerous of the two
>   because it applies to every TLS connection the process makes — your database, your secrets
>   manager, every outbound API — not just the one that was failing. Node prints a warning about it on
>   startup for exactly this reason. `npm config set strict-ssl false` is the same trade applied to
>   every package you will ever install.


## 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_CHAIN` means 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 use `npm 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](https://nodejs.org/api/tls.html) — how Node assembles its trust store and what the tls options override
- [openssl-verify(1)](https://docs.openssl.org/3.5/man1/openssl-verify/) — the -untrusted flag used to prove a chain validates with the intermediate supplied

## Related pages

- https://certguru.io/errors/unable-to-get-local-issuer-certificate/
- https://certguru.io/errors/self-signed-certificate-in-chain/
- https://certguru.io/concepts/certificate-chain/
- https://certguru.io/concepts/root-vs-intermediate-ca/
- https://certguru.io/openssl/verify-certificate-chain/
- https://certguru.io/errors/certificate-verify-failed-python/
- https://certguru.io/platforms/nginx/
- https://certguru.io/errors/curl-60-ssl-certificate-problem/

--
CertGuru — https://certguru.io
