# self signed certificate in certificate chain

The error "self signed certificate in certificate chain" (OpenSSL verify error 19) means the chain ended at a self-signed root that is not in your trust store. Every chain ends at a self-signed root — that is what a root is — so the error is not about the certificate being self-signed but about the root being *unknown*. The two common causes are a corporate TLS-inspecting proxy re-signing traffic with its own CA, and a private CA whose root has not been installed. The fix is to add that root to the relevant trust store, not to disable verification.

Source: https://certguru.io/errors/self-signed-certificate-in-chain/
Last updated: 2026-08-19
Commands verified against OpenSSL 3.5.7.

---

> **The exact message**
>
> ```
>   Verify return code: 19 (self signed certificate in certificate chain)
>   ```
>   npm reports `SELF_SIGNED_CERT_IN_CHAIN`. Python reports
>   `certificate verify failed: self signed certificate in certificate chain`.

## What it actually means

Every certificate chain ends at a self-signed root — a root certificate signs itself, which is what
makes it a root. So the error is not complaining that a certificate is self-signed. It is saying:

> I followed the chain to its end, found a self-signed root there, and **I do not have that root in
> my trust store.**

The chain is structurally fine. The trust anchor is unknown.

## Step 1 — see which root you are being offered

*Print the subject of every certificate the server sends*

```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts /dev/null \
  | grep -E "^ *[0-9]+ s:"
```

Expected output:

```
0 s:CN = example.com
1 s:CN = Contoso Security Appliance CA
```

The last line is the root. What it says tells you which of the two causes you have.

## Cause A — a TLS-inspecting proxy

If the root names your employer, a firewall vendor, or something like "Zscaler", "Netskope",
"Fortinet" or "Blue Coat", your traffic is being intercepted and re-signed by a corporate proxy.
This is intentional on managed networks: the proxy terminates TLS, inspects the traffic, and
re-encrypts it with a certificate from its own CA.

Managed machines have that CA root installed automatically. Containers, virtual machines and
freshly-imaged laptops usually do not.

*Save the root the proxy is presenting*

```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts /dev/null \
  | awk '/BEGIN CERT/{n++} n==2' \
  > corporate-root.crt
```

> **Confirm it before trusting it**
>
> Get the corporate root from your IT department rather than from the connection that is failing. A
>   root harvested from an intercepted connection is, by construction, supplied by whoever is doing
>   the intercepting. On a managed corporate network that is fine; on a coffee-shop network it is
>   exactly the attack this error exists to prevent.

## Cause B — your own private CA

If the root names your own organisation's CA, it simply has not been installed on this machine. See
[create a root CA](/openssl/create-root-ca/) for the distribution step.

## Installing the root

#### Debian / Ubuntu

```bash
sudo cp corporate-root.crt /usr/local/share/ca-certificates/corporate-root.crt
sudo update-ca-certificates
```

#### RHEL / Fedora

```bash
sudo cp corporate-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract
```

#### Alpine

```bash
sudo apk add ca-certificates
sudo cp corporate-root.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```

#### macOS

```bash
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain corporate-root.crt
```

## Runtimes that ignore the system store

Fixing the OS store frequently does not fix the error, because several runtimes bundle their own
CA list:

| Runtime | Fix |
| --- | --- |
| Node.js | `export NODE_EXTRA_CA_CERTS=/path/to/corporate-root.crt` |
| npm | `npm config set cafile /path/to/ca-bundle.crt` |
| Python `requests` | `export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.crt` |
| Python `ssl`, pip | `export SSL_CERT_FILE=/path/to/ca-bundle.crt` |
| pip | `pip config set global.cert /path/to/ca-bundle.crt` |
| Git | `git config --global http.sslCAInfo /path/to/ca-bundle.crt` |
| Java | `keytool -importcert -trustcacerts -alias corp-root -file corporate-root.crt -keystore $JAVA_HOME/lib/security/cacerts` |
| Go | Uses the system store; `SSL_CERT_FILE` also works |
| curl | `curl --cacert /path/to/corporate-root.crt` |

## In a Dockerfile

```dockerfile
COPY corporate-root.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
```

## What not to do

> **These make the error vanish, not the problem**
>
> `npm config set strict-ssl false`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `pip --trusted-host`,
>   `GIT_SSL_NO_VERIFY=true` and `curl -k` all disable verification wholesale. On a network where
>   traffic is already being intercepted, that removes the one check that would tell you if the
>   interception were hostile rather than corporate.


## Frequently asked questions

### How is this different from "unable to get local issuer certificate"?

Error 20 means the chain was incomplete — a link was missing. Error 19 means the chain was complete but ended at a root you do not trust. These are different problems; the first is the server's fault, the second is a local trust configuration issue.

### Why did this start happening on a new laptop or in a new container?

The trust store changed. A corporate root that was installed on the old machine by device management is absent on the new one, or a container image ships without it. The network did not change; your trust store did.

### Is NODE_TLS_REJECT_UNAUTHORIZED=0 an acceptable fix?

No. It disables certificate verification for every connection the process makes, so the traffic is encrypted but unauthenticated. Use it to confirm a diagnosis and never in anything that ships.

### How do I fix this for npm specifically?

Point npm at a CA bundle containing the root — `npm config set cafile /path/to/ca-bundle.crt`. Do not use `npm config set strict-ssl false`, which turns verification off for all package downloads.


## References

- [openssl-verify(1)](https://docs.openssl.org/3.5/man1/openssl-verify/) — the verify error this maps to
- [RFC 4158: certification path building](https://www.rfc-editor.org/rfc/rfc4158) — why a verifier can fail to find a path that exists

## Related pages

- https://certguru.io/errors/unable-to-get-local-issuer-certificate/
- https://certguru.io/concepts/certificate-chain/
- https://certguru.io/errors/unable-to-verify-leaf-signature/
- https://certguru.io/concepts/root-vs-intermediate-ca/
- https://certguru.io/openssl/verify-certificate-chain/
- https://certguru.io/concepts/self-signed-vs-ca-signed/
- https://certguru.io/errors/certificate-verify-failed-python/
- https://certguru.io/errors/x509-signed-by-unknown-authority-go/

--
CertGuru — https://certguru.io
