A TLS client trusts a certificate only if it can build a chain from that certificate up to a root it already trusts. The server is expected to send its own certificate (the leaf) and every intermediate certificate in between; the client supplies the root from its trust store. When a link is missing, expired or doesn’t match, you get a chain error.
Match the error to the cause
The same five problems show up under different names in every client. These are the exact messages from OpenSSL 3.6, Node.js 24 and Python 3.14 (via urllib), and the error codes from Chromium 156, captured on October 14, 2026 against the badssl.com test sites:
Missing intermediate certificate
- OpenSSL
s_client:Verify return code: 21 (unable to verify the first certificate) - Node.js:
UNABLE_TO_VERIFY_LEAF_SIGNATURE - Python:
unable to get local issuer certificate - Chromium: often loads anyway (see why the browser works)
Expired certificate
- OpenSSL:
10 (certificate has expired) - Node.js:
CERT_HAS_EXPIRED - Python:
certificate has expired - Chromium:
ERR_CERT_DATE_INVALID(some Chrome builds reportERR_CERT_AUTHORITY_INVALIDfor this test site, because its old chain is no longer trusted either)
Hostname not on the certificate
- OpenSSL:
62 (hostname mismatch), but only when you add-verify_hostname - Node.js:
ERR_TLS_CERT_ALTNAME_INVALID - Python:
Hostname mismatch, certificate is not valid for '…' - Chromium:
ERR_CERT_COMMON_NAME_INVALID
Self-signed certificate
- OpenSSL:
18 (self-signed certificate) - Node.js:
DEPTH_ZERO_SELF_SIGNED_CERT - Python:
self-signed certificate - Chromium:
ERR_CERT_AUTHORITY_INVALID
Chain ends in an untrusted root
- OpenSSL:
19 (self-signed certificate in certificate chain) - Node.js:
SELF_SIGNED_CERT_IN_CHAIN - Python:
self-signed certificate in certificate chain - Chromium:
ERR_CERT_AUTHORITY_INVALID
curl’s wording depends on its version and TLS library, but it contains the same phrases: SSL certificate problem: certificate has expired, unable to get local issuer certificate, no alternative certificate subject name matches target host name. Firefox reports an unknown or missing issuer as SEC_ERROR_UNKNOWN_ISSUER.
Read the chain a server sends
openssl s_client -showcerts prints every certificate the server sent, in order. For each one, s: is the subject (who the certificate is for) and i: is the issuer (who signed it). In a healthy chain, each certificate’s issuer is the next certificate’s subject:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| grep -E '^Certificate chain| [0-9]+ s:| i:|Verify return code'
Certificate chain
0 s:CN=example.com
i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
2 s:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
i:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
3 s:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
i:C=GB, ST=Greater Manchester, L=Salford, O=Comodo CA Limited, CN=AAA Certificate Services
Verify return code: 0 (ok)
Now a server that forgot its intermediate. It sends only the leaf, whose issuer (YR1, a Let’s Encrypt intermediate) is nowhere in the output:
openssl s_client -connect incomplete-chain.badssl.com:443 -servername incomplete-chain.badssl.com -showcerts </dev/null 2>/dev/null \
| grep -E '^Certificate chain| [0-9]+ s:| i:|Verify return code'
Certificate chain
0 s:CN=*.badssl.com
i:C=US, O=Let's Encrypt, CN=YR1
Verify return code: 21 (unable to verify the first certificate)
Two details trip people up:
s_clientdoes not check the hostname unless asked. Againstwrong.host.badssl.comit reportsVerify return code: 0 (ok)until you add-verify_hostname wrong.host.badssl.com. Then it returns62 (hostname mismatch).- The chain section is all the server sent, not all you need. Most clients other than browsers add only the root, from their trust store; they don’t go looking for missing intermediates.
Check a chain file before you deploy it
To test certificate files on disk, split the bundle into one file per certificate and let openssl verify build the chain. This splits whatever s_client -showcerts printed into cert0.pem, cert1.pem and so on:
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERTIFICATE/{f="cert" (n++) ".pem"} /BEGIN CERTIFICATE/,/END CERTIFICATE/{print > f}'
cp cert0.pem leaf.pem
cat cert1.pem cert2.pem cert3.pem > intermediates.pem
openssl verify -untrusted intermediates.pem leaf.pem
openssl verify leaf.pem
leaf.pem: OK
CN=letsencrypt.org
error 20 at 0 depth lookup: unable to get local issuer certificate
error leaf.pem: verification failed
With the intermediates supplied, the chain verifies; without them, OpenSSL can’t find the leaf’s issuer, which is exactly what a client sees when the server doesn’t send them. Use -untrusted for intermediates, not -CAfile: -CAfile is for trusted roots, and a file of intermediates alone fails there with unable to get issuer certificate.
Why the browser works and curl doesn’t
A missing intermediate is the error most likely to pass unnoticed, because browsers often repair it:
- Firefox “pre-downloads all trusted Web Public Key Infrastructure (PKI) intermediate CA certificates”, according to Mozilla, precisely so users don’t hit this misconfiguration.
- Other browsers fetch the missing certificate in the background from the address embedded in the leaf (its Authority Information Access, or AIA, field), as Mozilla’s notes describe. The incomplete-chain leaf above names
http://yr1.i.lencr.org/as its issuer’s address. - macOS’s bundled curl uses Apple’s LibreSSL, which also consults the macOS system trust store (curl’s author has written about this in the context of
--cacert). In our test it loadedincomplete-chain.badssl.comwithout an error.
Command-line tools on Linux, Node.js, Python, Go, Java, mobile apps, webhooks and API clients generally do none of this. So “it works in my browser” proves nothing about the chain; test with openssl s_client or one of the clients above.
Fix it
Missing intermediate: serve the full chain
Configure the server with a file that contains the leaf certificate first, followed by the intermediates. Certbot already writes this file as fullchain.pem: “the server certificate is the first one in this file, followed by any intermediates”. cert.pem on its own is the usual culprit.
If your CA sent the certificate and the intermediates as separate files, join them in that order:
cat example.com.crt intermediate-bundle.crt > example.com.fullchain.crt
nginx. Point ssl_certificate at the full chain. Per the nginx documentation, “the primary certificate comes first, then the intermediate certificates.” If the order is wrong, nginx refuses to start with a key values mismatch error (details).
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;
}
Then sudo nginx -t && sudo systemctl reload nginx.
Apache httpd 2.4.8 or later. SSLCertificateFile accepts the leaf plus intermediates, “sorted from leaf to root”; the old SSLCertificateChainFile directive is deprecated.
<VirtualHost *:443>
ServerName example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
</VirtualHost>
Then sudo apachectl configtest && sudo systemctl reload apache2 (the service is httpd on Red Hat family systems).
Caddy. With automatic HTTPS, Caddy obtains and renews certificates itself and you configure nothing. If you bring your own files with the tls directive, the documentation only says they are “the certificate and private key PEM files”; give it the full-chain file anyway, so clients receive the intermediates:
example.com {
tls /etc/ssl/example.com.fullchain.crt /etc/ssl/example.com.key
}
Cloudflare. Two separate chains are involved when Cloudflare proxies your site:
- Visitors to Cloudflare. Cloudflare serves the edge certificate. If you upload a custom certificate, its bundle method decides the chain: Compatible (the default) and Modern let Cloudflare pick intermediates; choose User-defined “if you must ensure the chain you upload is the one used”.
- Cloudflare to your origin. In Full (strict) mode, Cloudflare requires the origin certificate to be unexpired, issued by a publicly trusted CA or Cloudflare’s Origin CA, and to match the hostname; otherwise visitors see error 526. A Cloudflare Origin CA certificate is trusted only by Cloudflare, so if you pause Cloudflare or turn off proxying for that hostname, visitors get untrusted-certificate errors.
Hostname mismatch: get a certificate that names the host
List the names a certificate covers:
echo | openssl s_client -connect wrong.host.badssl.com:443 -servername wrong.host.badssl.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:*.badssl.com, DNS:badssl.com
On macOS’s bundled LibreSSL openssl, which lacks -ext, use openssl x509 -noout -text | grep -A1 'Subject Alternative Name' instead.
A wildcard covers exactly one label: *.badssl.com matches wrong.badssl.com but not wrong.host.badssl.com, which is why this site fails. Fix it by adding the exact name to the certificate (or a wildcard at the right level), or by making sure the server picks the right certificate for that name: on servers with several certificates, check that the server_name (nginx) or ServerName/ServerAlias (Apache) matches, and that clients send SNI.
Expired leaf or intermediate
An expired leaf needs renewing, then a reload of the server that serves it. An expired intermediate usually means an old bundle file: the CA issued a new intermediate, and your server still sends the old one. Download the current chain from your CA (or let your ACME client write a fresh fullchain.pem) and replace the bundle. Check SSL expiry: openssl, curl, browser shows how to read each date.
Self-signed or private root
A certificate signed by your own CA works only for clients that trust that CA. For a public site, replace it with a certificate from a publicly trusted CA. For internal services, install your root certificate in each client’s trust store (and, for Node.js, NODE_EXTRA_CA_CERTS points it at an extra CA file). Turning verification off “to make it work” removes the protection TLS exists for.
After the fix
- Reload the server, and confirm the reload actually happened.
- Re-run
openssl s_client -showcertsand checkVerify return code: 0 (ok). - Test from a client without browser repairs:
curl -sI https://example.comon Linux, or the Node.js one-linernode -e "require('https').get('https://example.com', r => { console.log(r.statusCode); r.resume(); }).on('error', e => console.log(e.code))", which prints200for a good chain and an error code such asUNABLE_TO_VERIFY_LEAF_SIGNATUREfor a broken one. - If you run several servers behind a load balancer, check each one:
openssl s_client -connect 203.0.113.10:443 -servername example.com.
Expiry Watch checks a site’s chain and hostname match along with its expiry date, from outside your network, so you see what clients see.
Sources
All sources were checked on October 14, 2026.
- Configuring HTTPS servers: SSL certificate chains and
ssl_certificate, nginx documentation. - mod_ssl:
SSLCertificateFileandSSLCertificateChainFile, Apache HTTP Server 2.4 documentation. - Automatic HTTPS and the
tlsdirective, Caddy documentation. - Encryption modes, Full (strict), Origin CA certificates and bundle methods, Cloudflare documentation.
- Where are my certificates?, Certbot documentation.
- Preloading Intermediate CA Certificates into Firefox, Mozilla Security Blog, November 13, 2020, and Intermediate Preloading, Mozilla Wiki.
- The Apple curl security incident 12604, Daniel Stenberg, March 8, 2024.
- badssl.com: the test sites used for every error above.