How HTTP Works

Debug guide · you're seeing

  • 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)

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.

Reviewed 8 min readintermediate26 sourcesTry itMarkdown
On this page

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 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 or a hostname mismatch.

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

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. The curl wording comes from its OpenSSL backend combined with OpenSSL’s error strings. curl builds with other TLS backends word error 60 differently.

Node’s 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.

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:

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

-showcerts 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:

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:

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 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, not new trust anchors.

Browsers are good at hiding this fault. Chromium’s built-in verifier 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 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. 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. For an existing certificate using Certbot’s file layout, point nginx at fullchain.pem:

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:

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 creates and installs a local CA, then issues certificates for the names you give it:

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. Compare the issuer and SHA-256 fingerprint you see on the affected network with what the same hostname shows on a separate, approved network:

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:

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:

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:

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

Debian’s store generator 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:

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 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 arrived in 23.8.0, gained support beyond Windows and macOS in 23.9.0, and was backported to 22.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() loads the default CA certificates unless you give it a CA location. Upstream Requests uses certifi instead, which may not match the operating system store. Check both from the interpreter that fails:

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:

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:

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 and OpenSSL’s error strings:

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 inside the existing location:

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 skips peer verification. NODE_TLS_REJECT_UNAUTHORIZED=0 turns off Node’s certificate validation. Requests’ verify=False 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.

Frequently asked questions

Why does the site work in Chrome but fail in curl or Node?

Usually the server leaves out an intermediate certificate. Chrome can download it through AIA and Firefox may already have it preloaded, but curl and Node cannot fill the gap. Clients also trust different roots, so check both the chain the server sends and the CA settings of the client that fails.

Should the server send its root certificate?

Sending a root does not make the client trust it. Serve the leaf followed by the required intermediates; the trust anchor belongs in the client’s trusted CA store.

Does NODE_EXTRA_CA_CERTS take effect without restarting Node?

No. Node reads this variable at process startup. An explicitly supplied TLS ca option also bypasses the default and extra CA certificates.

Does updating the operating system CA store fix Python Requests?

Not necessarily. Upstream Requests uses certifi, while Python’s standard ssl module can load platform and OpenSSL defaults. Check requests.certs.where() and any REQUESTS_CA_BUNDLE override in the failing environment.

Can curl -k or verify=False repair a certificate chain?

No. They disable certificate verification rather than repairing the chain or establishing trust. Keep verification enabled and repair the served intermediates or configure an authenticated CA certificate.

Sources

  1. MDN: Transport Layer Securitydeveloper.mozilla.org
  2. RFC 8446 Section 4.4.2: certificate listrfc-editor.org
  3. Chromium: certificate error definitionsgithub.com
  4. Chromium: built-in verifier and AIA fetchinggithub.com
  5. Mozilla: intermediate CA preloadingblog.mozilla.org
  6. Mozilla: decision against per-connection AIA fetchingbugzilla.mozilla.org
  7. Firefox: certificate error page sourcesearchfox.org
  8. Mozilla: unknown issuer and TLS interceptionsupport.mozilla.org
  9. curl: OpenSSL certificate failure formattinggithub.com
  10. OpenSSL: certificate verification error stringsgithub.com
  11. curl: CA stores and verificationcurl.se
  12. OpenSSL: s_clientdocs.openssl.org
  13. OpenSSL: verifydocs.openssl.org
  14. OpenSSL: trust store verification optionsdocs.openssl.org
  15. nginx: HTTPS certificate chainsnginx.org
  16. Certbot: certificate file locationseff-certbot.readthedocs.io
  17. nginx: upstream certificate verificationnginx.org
  18. nginx: upstream verification log sourcegithub.com
  19. mkcert: local CA installation and Node configurationgithub.com
  20. Debian: update-ca-certificatesmanpages.debian.org
  21. Node.js: TLS error codesnodejs.org
  22. Node.js: CA environment variables and flagsnodejs.org
  23. Node.js 22.15.0: system CA support backportnodejs.org
  24. Python: ssl default trust and verificationdocs.python.org
  25. CPython: certificate verification exceptionsgithub.com
  26. Requests: certifi and explicit CA bundlesrequests.readthedocs.io

Keep going

Browse /search