Debug guide · you're seeing
NET::ERR_CERT_AUTHORITY_INVALIDSEC_ERROR_UNKNOWN_ISSUERMOZILLA_PKIX_ERROR_SELF_SIGNED_CERTcurl: (60) SSL certificate problem: unable to get local issuer certificatecurl: (60) SSL certificate problem: self-signed certificateUNABLE_TO_VERIFY_LEAF_SIGNATURESELF_SIGNED_CERT_IN_CHAINDEPTH_ZERO_SELF_SIGNED_CERTCERTIFICATE_VERIFY_FAILEDupstream 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.
On this page
- What it means
- Find the cause
- 1. The server sends the leaf but omits an intermediate
- 2. A local or private CA is absent from this client’s store
- 3. A TLS-inspecting proxy replaces the public certificate
- 4. An old system or container has a stale CA bundle
- Configure the runtime that actually fails
- Node.js
- Python and Requests
- nginx connecting to an HTTPS upstream
- Keep verification enabled
- Related
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/nullfrom 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.
Related
- Certificate date errors: expiry, renewal deployment and clock checks.
- Certificate hostname errors: the certificate covers a different name.
- HTTPS and TLS: how the handshake and certificate chain fit together.
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
- MDN: Transport Layer Securitydeveloper.mozilla.org
- RFC 8446 Section 4.4.2: certificate listrfc-editor.org
- Chromium: certificate error definitionsgithub.com
- Chromium: built-in verifier and AIA fetchinggithub.com
- Mozilla: intermediate CA preloadingblog.mozilla.org
- Mozilla: decision against per-connection AIA fetchingbugzilla.mozilla.org
- Firefox: certificate error page sourcesearchfox.org
- Mozilla: unknown issuer and TLS interceptionsupport.mozilla.org
- curl: OpenSSL certificate failure formattinggithub.com
- OpenSSL: certificate verification error stringsgithub.com
- curl: CA stores and verificationcurl.se
- OpenSSL: s_clientdocs.openssl.org
- OpenSSL: verifydocs.openssl.org
- OpenSSL: trust store verification optionsdocs.openssl.org
- nginx: HTTPS certificate chainsnginx.org
- Certbot: certificate file locationseff-certbot.readthedocs.io
- nginx: upstream certificate verificationnginx.org
- nginx: upstream verification log sourcegithub.com
- mkcert: local CA installation and Node configurationgithub.com
- Debian: update-ca-certificatesmanpages.debian.org
- Node.js: TLS error codesnodejs.org
- Node.js: CA environment variables and flagsnodejs.org
- Node.js 22.15.0: system CA support backportnodejs.org
- Python: ssl default trust and verificationdocs.python.org
- CPython: certificate verification exceptionsgithub.com
- Requests: certifi and explicit CA bundlesrequests.readthedocs.io
Related
NET::ERR_CERT_COMMON_NAME_INVALID: Fix the Name Mismatch
Fix NET::ERR_CERT_COMMON_NAME_INVALID and related ERR_CERT errors: SAN vs CN, wildcard limits, missing SNI, expired or incomplete chains, with openssl checks.
ERR_CERT_DATE_INVALID: Expired Certificates and Fixes
Fix NET::ERR_CERT_DATE_INVALID by checking certificate dates, client clocks, failed renewals, stale nginx deployments and expired intermediate certificates.
HTTPS Explained: How TLS Secures HTTP
HTTPS is HTTP over TLS. What the TLS handshake does, how certificates prove identity, what HTTPS hides and what it does not, and how to migrate a site safely.
ERR_SSL_PROTOCOL_ERROR: Causes and Fixes in Chrome
Fix ERR_SSL_PROTOCOL_ERROR: plain HTTP on port 443, TLS version mismatches, SNI, TLS-inspecting proxies and Cloudflare, with curl and openssl checks.