Two different problems behind one warning

When a certificate “doesn’t match”, one of two quite different things is going on:

  • Hostname mismatch. The certificate is valid and trusted, but the name you connected to isn’t on it. Chrome says NET::ERR_CERT_COMMON_NAME_INVALID, Firefox SSL_ERROR_BAD_CERT_DOMAIN, and libraries say “hostname mismatch”, “certificate doesn’t match host” or “no alternative certificate subject name matches”.
  • Incomplete chain. The name is right, but the client can’t connect the certificate to a root it trusts, because the server didn’t send the intermediate certificate in between. Chrome says NET::ERR_CERT_AUTHORITY_INVALID (when it shows anything at all), curl says unable to get local issuer certificate, Java says PKIX path building failed, and .NET says PartialChain.

The fixes don’t overlap. Reissuing the certificate with more names does nothing for a missing intermediate, and installing the intermediate does nothing for a missing name. So find out which one you have before changing anything.

One command that answers both questions

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

Always pass -servername with the exact name that fails. Without it the server may hand you its default certificate, and you’ll be debugging the wrong one.

Read three parts of the output.

The chain the server sent, near the top:

Certificate chain
 0 s:CN = www.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1

s: is the subject, i: is the issuer. A healthy chain is a staircase: each certificate’s issuer is the subject of the next line. If you only see line 0, the intermediate is missing.

The verification result, near the bottom:

Verify return code: 0 (ok)

With a missing intermediate you get this instead:

Verify return code: 21 (unable to verify the first certificate)

The names on the leaf, if you suspect a hostname mismatch:

openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -ext subjectAltName

If the name you typed isn’t in the Subject Alternative Name list (wildcards cover exactly one label), it’s a hostname mismatch. That case has its own guide with the seven usual causes: NET::ERR_CERT_COMMON_NAME_INVALID. The rest of this page is about the chain.

The SSL check does the same thing from outside your network: it completes a real handshake from a server in Seoul, reads the certificate that’s actually served and verifies the chain against the standard Mozilla root list. It does not download missing intermediates, so it reports a broken chain the way curl or an Android app would see it, not the way Chrome papers over it. It doesn’t check revocation.

Why it works in Chrome and fails everywhere else

This is what makes an incomplete chain so confusing. You open the site in Chrome, padlock, everything fine. Then a payment provider’s webhook fails, an Android app can’t connect, a monitoring check goes red, or a colleague’s curl complains.

Every certificate carries an Authority Information Access (AIA) extension with a URL where its issuer’s certificate can be downloaded. When a server forgets to send the intermediate:

  • Chrome, Edge and Safari notice the gap, fetch the intermediate from the AIA URL, and carry on. The user never knows.
  • Firefox doesn’t fetch AIA URLs, but current desktop versions ship the intermediates of all publicly trusted CAs and also remember intermediates they’ve seen. So Firefox often works too, which makes testing with “another browser” useless here.
  • curl, OpenSSL, Python, Node.js, Java, Go on Linux and Android’s TLS stack do none of this. They trust what the server sends plus their root store. A missing intermediate is a hard failure.

The result is a bug that’s invisible from a desktop browser and fatal for API clients and mobile apps. Never conclude “the certificate is fine” from a browser alone. Use openssl s_client or an external checker.

What the failure looks like in each client:

curl: (60) SSL certificate problem: unable to get local issuer certificate

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate

javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

Error: unable to verify the first certificate      (Node.js, UNABLE_TO_VERIFY_LEAF_SIGNATURE)

The remote certificate is invalid because of errors in the certificate chain: PartialChain   (.NET)

One caveat on .NET: it can download missing intermediates itself on both Windows and Linux. So PartialChain there often means the download was blocked too (a container or server without outbound HTTP), or that the chain ends in a private CA root that isn’t installed in that container. Check openssl s_client first; if the server sends a full chain and .NET still says PartialChain, it’s the client’s trust store.

The same error messages also appear when the client is the problem: a slim Docker image without the ca-certificates package, or a corporate proxy re-signing traffic with its own root. If openssl s_client from a normal machine shows a full chain and Verify return code: 0, the server is fine.

The usual causes on the server

1. cert.pem configured instead of fullchain.pem

By far the most common. certbot writes four files into /etc/letsencrypt/live/example.com/:

FileContains
cert.pemthe leaf certificate only
chain.pemthe intermediate(s) only
fullchain.pemleaf followed by intermediate(s)
privkey.pemthe private key

nginx expects everything in one file:

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

Find what nginx is actually loading, across all included files:

sudo nginx -T 2>/dev/null | grep -n 'ssl_certificate'

Apache 2.4.8 and later also takes the full chain in SSLCertificateFile; SSLCertificateChainFile is deprecated. On older Apache, SSLCertificateFile gets cert.pem and SSLCertificateChainFile gets chain.pem.

Certificates from a commercial CA usually come as a zip with your certificate plus a “CA bundle” or “intermediate” file. Build the full chain yourself, leaf first:

cat www_example_com.crt ca-bundle.crt > fullchain.pem

Other places the same mistake happens:

  • HAProxy: the file in crt must contain the leaf, the intermediates and the private key.
  • Kubernetes: the tls.crt key of a TLS secret must hold the full chain, not just the leaf.
  • AWS ACM import, and most load balancer and CDN upload forms: there’s a separate “certificate chain” field. Leave it empty and you’ve uploaded a leaf-only chain.
  • Node.js: https.createServer({ cert, key }) sends whatever cert contains. Read fullchain.pem there.
  • IIS and other Windows servers: they send the chain Windows can build from the local certificate stores. If the intermediate isn’t in the computer’s Intermediate Certification Authorities store, typically because a PFX was imported without it, the server sends the leaf alone.

2. The chain is in the wrong order

The leaf must come first, then the intermediate that signed it, then the next one up. If you concatenate the other way round (cat ca-bundle.crt www_example_com.crt), nginx pairs your private key with the first certificate in the file, which is now the intermediate, and refuses to load:

SSL_CTX_use_PrivateKey("/etc/nginx/ssl/example.key") failed
(SSL: error:05800074:x509 certificate routines::key values mismatch)

(Older OpenSSL versions print error:0B080074:x509 certificate routines:X509_check_private_key:key values mismatch.) When the leaf is first but the intermediates are shuffled, most modern clients sort it out, but older ones don’t, and there’s no reason to rely on it.

List what’s in a bundle, in order:

openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout

Check the staircase: the first entry’s issuer must be the second entry’s subject, and so on.

3. The certificate and private key don’t belong together

This happens after a renewal or reissue where the new certificate was copied over but the key from the old request stayed, or the other way round. The server usually refuses to start or reload with the key values mismatch error above.

Compare the public key in the certificate with the one derived from the private key. This works for RSA and ECDSA keys alike:

openssl x509 -in cert.pem -noout -pubkey | openssl sha256
openssl pkey -in privkey.pem -pubout | openssl sha256

The two hashes must be identical. The older modulus comparison (openssl x509 -noout -modulus against openssl rsa -noout -modulus) only works for RSA keys.

The dangerous part is what happens next. If systemctl reload nginx fails, nginx keeps running with the old configuration and the old certificate. Everything looks fine until the old certificate expires. Always run nginx -t (or apachectl configtest) and read the result before reloading, and check journalctl -u nginx after.

4. You fixed the files, but something else is serving the certificate

You edited the right file, reloaded, and openssl s_client still shows the old chain. Look at what is actually terminating TLS:

  • A CDN, load balancer or reverse proxy in front of the server presents its own certificate. Your origin’s files don’t matter to visitors. Fix the chain where it’s uploaded.
  • Several servers behind one name, and you only updated one. Test each IP with openssl s_client -connect 203.0.113.10:443 -servername www.example.com.
  • The DNS name points somewhere else than you think. A DNS lookup shows where it resolves right now.
  • A different server block answered because the server_name didn’t match the SNI, so a different ssl_certificate was used.

5. The intermediate itself is outdated

CAs rotate intermediates. If you saved an intermediate years ago and keep concatenating it with new leaf certificates, the chain no longer lines up: the leaf’s issuer won’t match the intermediate’s subject. Always take the chain that came with the current certificate (certbot’s fullchain.pem is regenerated on every renewal).

If you only have the leaf, its AIA URL tells you where the issuer lives:

openssl x509 -in cert.pem -noout -ext authorityInfoAccess
curl -s http://r11.i.lencr.org/ | openssl x509 -inform DER -out intermediate.pem

The URL in the second line is only an example; use the one your certificate prints. These downloads are usually DER encoded, hence -inform DER.

Check the files before you deploy

openssl verify builds the chain the same way strict clients do:

openssl verify -untrusted chain.pem cert.pem

cert.pem: OK means the leaf chains to a root in your system store using only the intermediates you supplied. If it fails here, it will fail for curl and Android in production too.

After the fix

  • Run openssl s_client -showcerts again and confirm you see at least two certificates and Verify return code: 0 (ok).
  • Test every hostname the certificate serves. Different server blocks often point at different files.
  • Re-run the SSL check. The chain field should read OK rather than Untrusted.
  • If a renewal hook or deployment script copies certificates around, fix the script too. Otherwise the next renewal brings the problem back.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.