Fix “certificate chain incomplete” and “unable to get local issuer certificate” errors

The site shows a padlock in Chrome on your laptop. Then it fails on an Android phone, the uptime monitor reports the certificate chain as incomplete, a Java client dies with PKIX path building failed, and curl on a fresh server says unable to get local issuer certificate. Same certificate, five verdicts. Almost always the cause is one thing: the server is not sending every certificate a client needs to walk from your certificate to a root it already trusts. This guide shows how to confirm that with one openssl command, tell which of four causes you have, build a correct fullchain.pem, install it on nginx, Apache, HAProxy, IIS or Java, and prove the fix from outside.

Why desktop browsers hide the problem

A public certificate is never trusted alone. Your leaf (the certificate for www.example.com) is signed by an intermediate CA certificate, which is signed by a root that is self-signed and shipped inside the operating system or browser. The client holds only the root; RFC 8446§4.4.2 makes the server supply the rest: the sender’s certificate comes first, each following certificate should directly certify the one before it, and the trust anchor may be omitted. A correct server therefore sends leaf, then intermediate(s), root omitted— two certificates under Let’s Encrypt, three under CAs with two intermediate tiers.

A missing intermediate goes unnoticed because of a leaf extension called Authority Information Access (OID 1.3.6.1.5.5.7.1.1), whose CA Issuersfield is an HTTP URL for the intermediate. Chrome, Edge and Safari on desktop perform “AIA fetching”: handed a lone leaf, they download the intermediate, verify and cache it. Browsers also keep intermediates seen on other sites, so a Chrome that visited any Let’s Encrypt site last week already holds that CA’s intermediate. Firefox does not fetch but preloads the public intermediates, with the same effect. The clients that fail do none of this:

  • Android’s platform verifier, used by every app on HttpsURLConnection, OkHttp or WebView — the error is Trust anchor for certification path not found.
  • OpenSSL and everything built on it: curl, wget, PHP, Ruby, Python’s ssl module.
  • Java, unless com.sun.security.enableAIAcaIssuers=true is set — the default is false.
  • Go, Node.js, .NET on Linux, and practically every monitoring probe and embedded device.

“Works in Chrome” proves nothing about the chain; the clients that complain are the honest ones.

The error messages, and what each one is telling you

Message (verbatim)Where you see itMost likely cause
curl: (60) SSL certificate problem: unable to get local issuer certificatecurl; Python’s [SSL: CERTIFICATE_VERIFY_FAILED]; OpenSSL verify code 20Missing intermediate, or a chain ending at a root the client lacks
unable to verify the first certificateNode.js (UNABLE_TO_VERIFY_LEAF_SIGNATURE); OpenSSL code 21Only the leaf was sent
PKIX path building failed: … unable to find valid certification path to requested targetJava, Tomcat, SpringMissing intermediate or untrusted root; Java never fetches
NET::ERR_CERT_AUTHORITY_INVALIDChrome, EdgeNo path to a trusted root even after AIA fetching: private, retired or dropped root
SEC_ERROR_UNKNOWN_ISSUERFirefoxIntermediate missing and not preloaded (private CA, brand-new intermediate)
Chain issues: IncompleteSSL Labs and similar checkers, uptime monitorsLeaf served alone; the checker completed the chain itself and says so
certificate has expired (code 10) at depth 1 or 2OpenSSL clientsAn intermediate or root in the sent chain has expired although the leaf is fresh
self signed certificate in certificate chain (code 19)OpenSSL clientsThe server sends a root the client does not trust

Four causes sit behind those wordings. Missing intermediate: the leaf is sent alone. Wrong order: the intermediate precedes the leaf — OpenSSL and modern browsers search the whole set, but older Android releases and many embedded stacks take the list literally. Untrusted root: the chain is complete but ends at a private CA, or at a root the client’s store predates. Expired intermediate or root: the leaf was renewed but the chain file beside it was not.

Diagnose it: read the depth lines from openssl s_client

Run this from anything that is not your desktop browser. -servernamematters: without it the server may hand you the default virtual host’s chain rather than the one for your hostname.

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

# healthy — verification climbs from your leaf (depth=0) to a trusted root (depth=2):
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R10
verify return:1
depth=0 CN = www.example.com
verify return:1
---
Certificate chain
 0 s:CN = www.example.com
   i:C = US, O = Let's Encrypt, CN = R10
 1 s:C = US, O = Let's Encrypt, CN = R10
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
...
Verify return code: 0 (ok)

# missing intermediate — it never gets past depth=0, and only one certificate is listed:
depth=0 CN = www.example.com
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = www.example.com
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
 0 s:CN = www.example.com
   i:C = US, O = Let's Encrypt, CN = R10
...
Verify return code: 21 (unable to verify the first certificate)

Read it in three places. The Certificate chain block lists what the server actually sent, numbered from 0; s: is a subject and i: its issuer, so a good chain reads like a ladder in which every i: equals the next s:. The depth= lines show how far verification climbed: an error at depth=0 means the leaf’s issuer was not found (missing intermediate); num=20 at depth=1 means the intermediate arrived but its issuer is not in the local store (untrusted or dropped root); num=19 means an untrusted self-signed root was sent; num=10 names the certificate that has expired. The last line must read Verify return code: 0 (ok), and entry 0 must be your hostname, or the order is wrong.

To inspect the served certificates, save them and drop the file on the SSL certificate checker:

openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts </dev/null 2>/dev/null \
  | sed -n '/BEGIN CERT/,/END CERT/p' > served-chain.pem

The checker takes that PEM (or any .crt/.der/.p7b/.pfx file) and reports the chain as supplied: a verdict such as Chain of 2 certificates links and verifies correctly. or Chain is broken — a signature or issuer link does not match., one line per link (is signed by; does not name as issuer for wrong order; is NOT validly signed bywhen the names match but the signature fails), and a card per certificate with role, days remaining, covered hostnames, algorithms and fingerprint. Two limits: it cannot connect to a live domain — a web page may not inspect another server’s handshake, hence the s_client capture — and a lone leaf is reported as a valid single certificate, because there is no link to test. One certificate in served-chain.pem where you expected two is itself the diagnosis.

The 2021 Let’s Encrypt root expiry: a valid chain that old clients rejected

The best-known chain incident involved no missing file. Let’s Encrypt originally chained to DST Root CA X3, an IdenTrust root every trust store already held, while its own ISRG Root X1spread through OS updates. DST Root CA X3 expired on 30 September 2021. To keep old Android phones working, Let’s Encrypt had ISRG Root X1 cross-signed by DST Root CA X3 with validity to 30 September 2024 — three years past the signer’s own expiry, which X.509 allows because each certificate’s dates are checked independently. The default chain became leaf → R3 → ISRG Root X1 (cross-signed by DST Root CA X3).

Clients split three ways. Those holding ISRG Root X1 stopped at R3’s issuer and ignored the cross-sign. Android before 7.1.1 lacked ISRG Root X1 but does not check a trust anchor’s expiry, so it accepted the path through the dead root. OpenSSL 1.0.2 and older built the longest path it could, hit the expired DST Root CA X3 in its own bundle and failed with certificate has expired; the client-side fix was deleting that root from the bundle or passing -trusted_first. Anything with no ISRG Root X1 at all (macOS before 10.12.1, iOS before 10, Java before 8u141) saw an unknown issuer. Server-side, certbot --preferred-chain "ISRG Root X1" requested the short chain, trading old-Android for old-OpenSSL support. The cross-sign expired on 30 September 2024, so a chain file still carrying the DST-signed certificate is now the expired root row above. The expired certificate guide covers the leaf-side story.

Build the fix: a correct fullchain.pem

You need the leaf and the intermediate(s) that issued it. Certbot writes cert.pem, chain.pem and, already concatenated, fullchain.pem under /etc/letsencrypt/live/<domain>/; commercial CAs attach an intermediate or a .ca-bundle to the issuance email. With nothing but the leaf, fetch it from the CA Issuers URL that openssl x509 -in cert.pem -noout -ext authorityInfoAccess prints (curl -o intermediate.der http://r10.i.lencr.org/for a current Let’s Encrypt RSA certificate — the download is DER, not PEM). Then concatenate:

cat cert.pem intermediate.pem > fullchain.pem
# leaf first, then each intermediate, root omitted; every block starting on its own line
grep -c 'BEGIN CERTIFICATE' fullchain.pem     # expect 2 (3 for a two-tier CA)
openssl verify -untrusted intermediate.pem cert.pem   # cert.pem: OK

If the CA sent a pile of certificates in no obvious order, paste them all into the SSL chain builderor drop the files on it — PEM or binary DER, so the AIA download works as-is. It finds the leaf (the one certificate that issued nothing else in the set), follows each issuer name to the next certificate and verifies every link’s signature against the next public key, labelling the result Leaf → Intermediate → Root. A name that matches but a signature that fails is flagged signature mismatch!, and certificates that do not belong are listed under Not part of this chain (left out). The download is fullchain.pem with the self-signed root removed; chain-with-root.pem is offered only when a root was present. Expired certificates trigger a warning, not a refusal, and nothing leaves the page — no private key is involved anyway. What the builder cannot do is invent a missing intermediate: feed it only the leaf and there is nothing to link.

Where each server expects the chain

ServerWhere the chain goesTrap
nginxssl_certificate /path/fullchain.pem; — leaf and intermediates in one file; ssl_certificate_key for the keyssl_trusted_certificate is for OCSP stapling and client certificates; it is never sent
Apache 2.4.8+SSLCertificateFile fullchain.pem — whole chain in one directiveSSLCertificateChainFile is deprecated but still needed before 2.4.8
HAProxybind :443 ssl crt /etc/haproxy/certs/example.pem — one file from cat fullchain.pem privkey.pemOne combined file per certificate, each with its own intermediates
IIS / WindowsA PFX containing the intermediates; importing it also fills the Intermediate Certification Authorities storeSchannel serves only what it can build from local stores — check with certutil -verify -urlfetch cert.cer
Java / TomcatA PKCS#12 keystore whose key entry carries the chain; keytool -list -v must show Certificate chain length: 2 or moreImporting the leaf alone with -importcert leaves the length at 1

For the Windows and Java rows the chain must travel inside the PKCS#12 file. The PEM to PFX tool takes the whole fullchain.pem in its certificate box, the private key in the second and a password for the archive, and packs certificate, intermediates and key into certificate.pfx or .p12. Before packing it checks that the key’s public half matches the first certificate; a mismatch stops with The private key does not match the first certificate — make sure the leaf (domain) certificate comes first and the key belongs to it., which also catches a chain pasted intermediate-first. RSA and P-256/P-384/P-521 keys are accepted, and the success line counts what went in: certificate.pfx created with 2 certificates + private key. — if it says 1, the PFX will reproduce the missing intermediate on IIS. The PFX guide and Java keystore guide take it from there.

Common mistakes

  • Including the root. It adds about a kilobyte per handshake, and once that root expires while your file still carries it, OpenSSL clients return code 10 or 19.
  • Renewing the leaf but keeping last year’s intermediate.Let’s Encrypt moved from R3 to R10/R11 in 2024, and R3 expired in September 2025. Copying only cert.pem into a static chain file leaves a leaf signed by R10 on top of R3; the checker says does not name as issuer and every non-fetching client fails.
  • Mixing RSA and ECDSA chains. nginx and Apache serve both from two ssl_certificatelines; each needs its own intermediates. Let’s Encrypt’s ECDSA leaves chain through E5/E6 to ISRG Root X2, not through R10/R11.
  • Key in the chain file, or chain in the key file. nginx refuses to start with key values mismatch or PEM_read_bio_X509_AUX() failed. Keep one PRIVATE KEY block in privkey.pem, only CERTIFICATE blocks in fullchain.pem, and run the pair through the certificate ↔ key matcher before reloading: it derives the public key from each and answers Everything matches or names the pair that disagrees.
  • Fixing the wrong virtual host. Certificates are chosen by SNI; if s_client without -servername shows a different chain from the run with it, the default server block has its own, older files.
  • Fixing the wrong machine.When a CDN, load balancer or proxy terminates TLS, the world sees the chain uploaded there; if certificate 0’s issuer names the CDN’s CA, the fix lives in that dashboard.
  • Editing without reloading. nginx, Apache and HAProxy read certificates at start-up and on reload, never per connection: nginx -t, then systemctl reload nginx.

Verify from outside, and why caches can lie to you

After the reload, re-run s_client and confirm three things: Certificate chain lists two (or three) entries, entry 0 is your hostname, and the last line is Verify return code: 0 (ok). Then curl -v https://www.example.com/ -o /dev/null, which prints SSL certificate verify ok.when the chain is good. Do it from a network that never saw the broken setup — mobile data, a CI runner, a colleague’s machine. If Java complained, keytool -printcert -sslserver www.example.com:443 shows exactly what Java receives.

Do not trust a desktop browser for the after-check: Chrome and Safari cached the intermediate when they fetched it and show a padlock either way. Two more caches mislead: a tab opened before the reload may reuse its TLS connection for minutes, and a client resuming a TLS session is not sent the chain again, so its old verdict stands until the ticket expires. Fresh connections from openssl and curl avoid both. Finally, run the new served-chain.pem through the checkerand note the intermediate’s expiry beside the leaf’s — the next break will come from a rotation, not a typo.

Do this

  • Run openssl s_client -connect host:443 -servername host -showcerts from a non-browser client; one certificate in the chain block means a missing intermediate.
  • Fetch the intermediate from the CA or the AIA CA Issuers URL and build fullchain.pem — leaf, intermediates, no root — by hand or with the chain builder.
  • Install it where the server reads it: ssl_certificate, SSLCertificateFile, the HAProxy combined file, or a PFX that carries the intermediates for IIS and Java.
  • Confirm certificate and key match, reload, and re-test with curl from a different network — never with the browser that already worked.
  • Deploy fullchain.pem from every renewal, not just cert.pem, so an intermediate rotation cannot break the chain silently.

Frequently asked questions

Why does my SSL certificate work on desktop but not on my phone?

Desktop Chrome, Edge and Safari download a missing intermediate certificate from the URL embedded in your certificate (AIA fetching) and cache it; Android's platform verifier, curl, Java and most apps do not. Your server is sending only the leaf certificate. Add the intermediate to fullchain.pem and reload the server.

Do I need to include the root certificate in fullchain.pem?

No. A client only trusts a root it already holds, so sending it adds about a kilobyte to every handshake for nothing, and if that root has expired — as DST Root CA X3 did in 2021 — some clients reject the whole chain. Send the leaf and the intermediates only.

Where do I download the missing intermediate certificate?

From your CA's chain download page, or from the CA Issuers URL in your certificate's Authority Information Access extension: openssl x509 -in cert.pem -noout -ext authorityInfoAccess prints it. The file it serves is usually DER; the chain builder accepts DER directly.

What is the difference between cert.pem, chain.pem and fullchain.pem?

Those are certbot's names: cert.pem is the leaf only, chain.pem is the intermediate(s) only, and fullchain.pem is cert.pem followed by chain.pem. Point the server at fullchain.pem and privkey.pem.

Can I fix "unable to get local issuer certificate" on the client instead?

As a workaround for a server you do not control, yes: give the client the intermediate with curl --cacert, Python's REQUESTS_CA_BUNDLE, or keytool -importcert into a Java truststore. Every other client will still fail, so the real fix is the server's chain.

Tools used in this guide

Every one of these runs in your browser — the files you work on never leave your device.

More certificates & keys guides