# ERR_CERT_AUTHORITY_INVALID: Chain and CA Trust Fixes

> Fix ERR_CERT_AUTHORITY_INVALID by inspecting missing intermediates, private CAs, TLS inspection and the CA stores actually used by curl, Node and Python.

Source: https://howhttpworks.com/debug/err-cert-authority-invalid
Last reviewed: 2026-10-05

Error messages this page covers:
- `NET::ERR_CERT_AUTHORITY_INVALID`
- `SEC_ERROR_UNKNOWN_ISSUER`
- `MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT`
- `curl: (60) SSL certificate problem: unable to get local issuer certificate`
- `curl: (60) SSL certificate problem: self-signed certificate`
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
- `SELF_SIGNED_CERT_IN_CHAIN`
- `DEPTH_ZERO_SELF_SIGNED_CERT`
- `CERTIFICATE_VERIFY_FAILED`
- `upstream SSL certificate verify error: (20:unable to get local issuer certificate)`

> **TL;DR:** The client couldn't link the server's certificate to a CA it trusts. Run `openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null` from the machine or container that fails. If the server leaves out an intermediate certificate, fix its certificate bundle. If the chain is complete but ends at a private or corporate CA, add that CA to the failing client's trust store. A site loading fine in your browser doesn't mean the chain is complete.

## What it means

Chrome's [`CERT_AUTHORITY_INVALID` definition](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) means it doesn't trust the authority that issued the certificate. Certificate validation failed during the TLS handshake, before any HTTPS request went out. That's a different problem from an [expired certificate](https://howhttpworks.com/debug/err-cert-date-invalid) or a [hostname mismatch](https://howhttpworks.com/debug/err-cert-common-name-invalid).

To trust a certificate, the client has to build a path from the server's leaf certificate, through any intermediate CAs, to a trust anchor it already has locally. [TLS 1.3's certificate-list rules](https://www.rfc-editor.org/rfc/rfc8446#section-4.4.2) put the leaf first, with each following certificate certifying the one before it. The server can leave out the root, because clients get trust anchors from their own stores. Sending a root the client doesn't already trust won't make it trusted.

Here's what the error looks like in different clients. These messages, and the other outputs on this page, are examples checked against client documentation and source code rather than captured from a live endpoint:

```text
NET::ERR_CERT_AUTHORITY_INVALID
SEC_ERROR_UNKNOWN_ISSUER
MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT
curl: (60) SSL certificate problem: unable to get local issuer certificate
curl: (60) SSL certificate problem: self-signed certificate
```

The Firefox names come from its [error-page source](https://searchfox.org/firefox-main/source/toolkit/content/aboutNetError.mjs). The curl wording comes from its [OpenSSL backend](https://github.com/curl/curl/blob/master/lib/vtls/openssl.c) combined with [OpenSSL's error strings](https://github.com/openssl/openssl/blob/master/crypto/x509/x509_txt.c). curl builds with other TLS backends word error 60 differently.

[Node's error codes](https://nodejs.org/api/errors.html#openssl-error-codes) split the problem three ways: `DEPTH_ZERO_SELF_SIGNED_CERT` (the leaf is self-signed and untrusted), `SELF_SIGNED_CERT_IN_CHAIN` (the chain ends at an untrusted self-signed certificate) and `UNABLE_TO_VERIFY_LEAF_SIGNATURE` (the available chain can't verify the leaf's signature). Python's `CERTIFICATE_VERIFY_FAILED` is broader. Read the reason that follows it, because the same exception also covers expiry and other validation failures. [CPython attaches that reason to its SSL exception](https://github.com/python/cpython/blob/main/Modules/_ssl.c).

## Find the cause

Check the chain the server sends first, then look at what the client trusts.

### 1. The server sends the leaf but omits an intermediate

Look at the certificates the server actually sends:

```bash
openssl s_client -connect example.com:443 -servername example.com \
  -showcerts </dev/null
```

[`-showcerts`](https://docs.openssl.org/3.6/man1/openssl-s_client/) prints the server's list as sent, which isn't the same as a verified chain. Copy the first PEM block into `leaf.pem` and the remaining intermediate blocks into `served-intermediates.pem`. Then inspect each certificate on its own:

```bash
openssl x509 -in leaf.pem -noout -subject -issuer -dates
openssl x509 -in intermediate.pem -noout -subject -issuer
```

An issuer name that matches the next subject is a hint, but only a signature check settles it. Verify against a trusted root bundle you know is good, plus intermediates obtained from the issuing CA:

```bash
openssl verify -no-CApath -no-CAstore -CAfile trusted-roots.pem -purpose sslserver \
  -verify_hostname example.com leaf.pem
openssl verify -no-CApath -no-CAstore -CAfile trusted-roots.pem -purpose sslserver \
  -verify_hostname example.com -untrusted ca-intermediates.pem leaf.pem
```

These commands use OpenSSL 3.x options. [`-no-CApath` and `-no-CAstore`](https://docs.openssl.org/3.6/man1/openssl-verification-options/) keep the system's default certificates out of the comparison, and `trusted-roots.pem` should hold only roots you've authenticated. If the first command fails and the second succeeds, the extra intermediates are what made the chain buildable. Compare them with what the server sent to spot the missing one. If the server did send intermediates, repeat with `-untrusted served-intermediates.pem`. [`-untrusted` supplies chain-building candidates](https://docs.openssl.org/3.6/man1/openssl-verify/), not new trust anchors.

Browsers are good at hiding this fault. [Chromium's built-in verifier](https://github.com/chromium/chromium/blob/main/net/cert/cert_verify_proc_builtin.cc) can download missing issuers through Authority Information Access (AIA) when network fetching is enabled and a fetcher is available. Firefox [preloads disclosed Web PKI intermediates in the background](https://blog.mozilla.org/security/2020/11/13/preloading-intermediate-ca-certificates-into-firefox/) into a local cache. Caching an intermediate doesn't make Firefox trust it more; it just has the certificate on hand. Firefox doesn't chase the leaf's AIA URL on each handshake, and Mozilla's [per-connection AIA-fetching proposal was closed WONTFIX](https://bugzilla.mozilla.org/show_bug.cgi?id=399324). Either way, the browser fills the gap and the page loads. curl does whatever its TLS backend and CA configuration allow, so it's often the first to fail.

**Fix the TLS terminator.** [nginx requires the leaf before the intermediates](https://nginx.org/en/docs/http/configuring_https_servers.html). For an existing certificate using [Certbot's file layout](https://eff-certbot.readthedocs.io/en/latest/using.html#where-are-my-certificates), point nginx at `fullchain.pem`:

```nginx
server {
    listen 443 ssl;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    location / { proxy_pass http://127.0.0.1:3000; }
}
```

Run `sudo nginx -t && sudo nginx -s reload`, then inspect the served chain again. With another CA, build the bundle yourself: the leaf first, then the intermediates it supplied, in order. If TLS terminates at a public load balancer or CDN, update the certificate there.

### 2. A local or private CA is absent from this client's store

Identify the issuer, and compare its CA fingerprint with the certificate your administrator distributed:

```bash
openssl x509 -in company-root.crt -noout -subject -fingerprint -sha256
curl --cacert company-root.crt -v https://internal.example.com/
```

If the request succeeds with that authenticated CA file, the problem is the client's trust store, so fix that next. A self-signed leaf usually has the same subject and issuer, but matching names alone don't prove it's self-signed, let alone safe to trust.

For local development, [mkcert](https://github.com/FiloSottile/mkcert) creates and installs a local CA, then issues certificates for the names you give it:

```bash
mkcert -install
mkcert localhost 127.0.0.1 ::1
NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" node app.js
```

Point your development server at the leaf and key paths mkcert prints. The root exists only on your machine, so other machines and containers need their own trust setup. When you share anything, share only the public CA certificate. Keep mkcert's `rootCA-key.pem` private: anyone holding it can issue certificates your machine will trust.

### 3. A TLS-inspecting proxy replaces the public certificate

[Mozilla documents unknown-issuer failures caused by HTTPS inspection](https://support.mozilla.org/en-US/kb/error-codes-secure-websites). Compare the issuer and SHA-256 fingerprint you see on the affected network with what the same hostname shows on a separate, approved network:

```bash
openssl s_client -connect example.com:443 -servername example.com </dev/null \
  | openssl x509 -noout -subject -issuer -fingerprint -sha256
curl -v https://example.com/ -o /dev/null
```

curl's verbose output shows whether an explicit proxy is in play. The OpenSSL command connects directly and ignores curl's proxy environment variables, so for an HTTP CONNECT proxy, repeat it with `s_client -proxy proxy.example.com:8080` and the same destination and SNI. Transparent inspection needs no proxy setting at all. Fingerprints also change during normal certificate rotation, so the stronger signal is an unexpected corporate issuer showing up on unrelated sites.

If the inspection is authorized, get the CA from your administrator through a trusted channel and install it in the runtime that actually fails. If you don't recognize the issuer, investigate it rather than importing whatever certificate the connection hands you.

### 4. An old system or container has a stale CA bundle

Check inside the failing environment, not just on the host:

```bash
curl --version
curl -v https://example.com/ -o /dev/null
dpkg-query -W ca-certificates
```

On Debian or Ubuntu, upgrade the package and regenerate the store:

```bash
sudo apt-get update
sudo apt-get install --only-upgrade ca-certificates
sudo update-ca-certificates
```

If the package isn't installed, drop `--only-upgrade`. In a Debian- or Ubuntu-based image, install it at build time:

```dockerfile
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*
```

[Debian's store generator](https://manpages.debian.org/bookworm/ca-certificates/update-ca-certificates.8.en.html) also picks up PEM certificates with a `.crt` extension in `/usr/local/share/ca-certificates`, one certificate per file. To add an approved corporate root, put it there and run `update-ca-certificates`. Containers carry their own CA bundle, so rebuild and redeploy the image; updating the host store won't reach it. And a package update can't fix a missing server intermediate or make a private CA publicly trusted.

## Configure the runtime that actually fails

### Node.js

Point `NODE_EXTRA_CA_CERTS` at a trusted PEM file to add it to Node's default roots:

```bash
NODE_EXTRA_CA_CERTS=/etc/company/company-root.pem node app.js
node --version
node --help | grep -- '--use-system-ca'
node --use-system-ca app.js
```

Node reads [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile) once at startup, so setting it inside a running app does nothing. If a TLS/HTTPS call passes its own `ca` option, that list replaces both the defaults and the extra certificates. When the variable seems to have no effect, check whether an SDK creates its own agent with a `ca` option.

[`--use-system-ca`](https://nodejs.org/api/cli.html#--use-system-ca) arrived in **23.8.0**, gained support beyond Windows and macOS in **23.9.0**, and was [backported to **22.15.0**](https://nodejs.org/en/blog/release/v22.15.0). It adds the system's trusted CAs on top of the bundled and extra ones. On Linux it reads OpenSSL's configured certificate file and directory. Check the installed binary supports it before adding the flag to a service definition.

### Python and Requests

Python's standard [`ssl.create_default_context()`](https://docs.python.org/3/library/ssl.html#ssl.create_default_context) loads the default CA certificates unless you give it a CA location. Upstream [Requests uses certifi](https://requests.readthedocs.io/en/latest/user/advanced/#ca-certificates) instead, which may not match the operating system store. Check both from the interpreter that fails:

```bash
python -c 'import ssl; print(ssl.get_default_verify_paths())'
python -c 'import requests; print(requests.certs.where())'
python -m pip show certifi
```

If certifi's public roots are out of date, upgrade it in the application's environment, or bump the pinned version and redeploy:

```bash
python -m pip install --upgrade certifi
```

For a private CA, build a bundle your application owns, containing the public roots you need plus the approved private root, and select it explicitly:

```python
import ssl
import requests

context = ssl.create_default_context(cafile="/etc/company/app-ca-bundle.pem")
response = requests.get(
    "https://internal.example.com/",
    verify="/etc/company/app-ca-bundle.pem",
    timeout=10,
)
response.raise_for_status()
```

Pass `context` to whichever standard-library client accepts it. Requests also honors `REQUESTS_CA_BUNDLE=/etc/company/app-ca-bundle.pem`. Keep the custom bundle under your own deployment control rather than editing certifi's installed file, which the next upgrade will overwrite.

### nginx connecting to an HTTPS upstream

Here's the nginx log line, checked against its [upstream source](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) and OpenSSL's error strings:

```text
upstream SSL certificate verify error: (20:unable to get local issuer certificate)
```

In this case nginx is the TLS client, so the browser's store is irrelevant. Fix the origin's chain or the CA file nginx trusts. For an HTTPS origin with an approved private root, adapt this [proxy configuration](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_verify) inside the existing location:

```nginx
proxy_pass https://origin.internal.example.com;
proxy_ssl_server_name on;
proxy_ssl_name origin.internal.example.com;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/company-roots.pem;
proxy_ssl_verify_depth 2;
```

A depth of 2 suits a short chain; match it to your real CA hierarchy. Leave upstream verification on while you test the fix.

## Keep verification enabled

[`curl -k`](https://curl.se/docs/sslcerts.html) skips peer verification. [`NODE_TLS_REJECT_UNAUTHORIZED=0`](https://nodejs.org/api/cli.html#node_tls_reject_unauthorizedvalue) turns off Node's certificate validation. Requests' [`verify=False`](https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification) accepts invalid certificates and ignores hostname mismatches. All three make the error go away by not checking. None of them installs an intermediate or tells you who you're really talking to.

Remove those bypasses, then retest with the original runtime, user and container that failed. If something only works with a bypass, all you've learned is that turning off the check changed the result. You still haven't confirmed you reached the intended server.

## Related

- [Certificate date errors](https://howhttpworks.com/debug/err-cert-date-invalid): expiry, renewal deployment and clock checks.
- [Certificate hostname errors](https://howhttpworks.com/debug/err-cert-common-name-invalid): the certificate covers a different name.
- [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls): how the handshake and certificate chain fit together.
