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.

Leaf certificate Intermediate certificate Root certificate example.com, sent by your server first sent by your server, after the leaf already in the client’s trust store signed by signed by
Each certificate is signed by the one below it. The server must send every certificate except the root.

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 report ERR_CERT_AUTHORITY_INVALID for 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_client does not check the hostname unless asked. Against wrong.host.badssl.com it reports Verify return code: 0 (ok) until you add -verify_hostname wrong.host.badssl.com. Then it returns 62 (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 loaded incomplete-chain.badssl.com without 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

  1. Reload the server, and confirm the reload actually happened.
  2. Re-run openssl s_client -showcerts and check Verify return code: 0 (ok).
  3. Test from a client without browser repairs: curl -sI https://example.com on Linux, or the Node.js one-liner node -e "require('https').get('https://example.com', r => { console.log(r.statusCode); r.resume(); }).on('error', e => console.log(e.code))", which prints 200 for a good chain and an error code such as UNABLE_TO_VERIFY_LEAF_SIGNATURE for a broken one.
  4. 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.