# Certificate chain errors: why it works in Chrome and fails elsewhere

> A missing intermediate certificate hides in desktop browsers and breaks apps, curl, webhooks and crawlers. Read the chain, match the error to its cause and install the full chain on any server.

Updated 2026-09-25 · Security · HTML version: https://getreport.app/guides/certificate-chain-errors-and-intermediate-certificates

The site opens fine in Chrome, the padlock is there, and yet the mobile app cannot log in, the payment provider's webhook fails and a customer on an old phone sees "Your connection is not private". The usual cause is a certificate chain that is missing its intermediate. Desktop browsers quietly fill the gap; almost everything else does not. This guide explains how the chain works, how to read it from the command line, what each error message means and how to install the full chain on common servers. The fix itself takes ten minutes.

## Quick answer

- Your server must send your certificate (the leaf) plus every intermediate certificate, in that order. It should not send the root.
- "unable to get local issuer certificate" or "unable to verify the first certificate" almost always means the intermediate is missing. Install the full chain file (`fullchain.pem` with Let's Encrypt), not the leaf alone (`cert.pem`).
- Check with `openssl s_client -connect example.com:443 -servername example.com` and look for "Verify return code: 0 (ok)".
- Do not trust a desktop browser as your test: it can fetch or remember the missing intermediate.
- Run the [SSL / TLS check](https://getreport.app/tools/ssl-check) after the change. It connects like a strict client, without that help.

## Why chain errors matter

A certificate is only trusted if the client can build a path from it to a root certificate in its trust store. Roots are pre-installed in operating systems and browsers. Certificate authorities do not sign your certificate with the root directly: the root signs an intermediate, and the intermediate signs your certificate. So the client needs three pieces:

1. **The leaf**: your certificate, for `www.example.com`.
2. **One or more intermediates**: the certificate authority's issuing certificates.
3. **The root**: already on the device.

The server's job is to send the first two. When it sends only the leaf, the client has to find the intermediate itself. Chrome and Safari can download it from the address written inside the leaf (the "Authority Information Access" field, AIA). Firefox ships a list of known intermediates. So on a laptop the site looks fine.

Most other clients do neither: curl and wget, Node.js, Python, Java and PHP HTTP clients, many Android apps, payment and webhook callers, monitoring tools, other companies' servers calling your API, and getReport's own fetch. For them, the chain ends at your leaf, nobody vouches for it, and the connection is refused. That is the "works in Chrome, fails everywhere else" pattern. The [TLS certificates explainer](https://getreport.app/learn/tls-certificates) covers the vocabulary in short.

## How getReport checks it

> **Free tool:** [SSL / TLS checker](https://getreport.app/tools/ssl-check): Certificate expiry and chain, TLS versions, HTTPS enforcement, HSTS and mixed content for any site — the checks that stand between your visitors and a browser warning.

getReport meets your certificate twice. First, the page fetch itself is a strict client: Node.js with its built-in list of trusted roots (Mozilla's), no AIA downloads and no cache of intermediates. If the chain, the expiry date or the hostname does not validate, the fetch is refused and the report stops with "We could not reach that site" and the error `NETWORK_ERROR`, before any module runs. On a site that other tools call healthy, that failed report is usually the first sign of a missing intermediate.

When the fetch succeeds over HTTPS, a separate TLS handshake with the hostname as SNI reads the certificate and runs these findings:

> **Check: The TLS certificate chain validates.** Browsers cannot verify who issued the certificate, so visitors see "Your connection is not private" instead of your page. The usual causes are a missing intermediate certificate, an expired certificate or a name mismatch.
>
> 1. Install the full chain (your certificate plus the intermediate) that your certificate authority provides, not just the leaf.
> 2. Check that the certificate covers exactly this hostname, with and without www.

The technical line carries the verification error code Node.js reports, for example `UNABLE_TO_VERIFY_LEAF_SIGNATURE` (missing intermediate), `CERT_HAS_EXPIRED`, `ERR_TLS_CERT_ALTNAME_INVALID` (hostname not in the certificate) or `SELF_SIGNED_CERT_IN_CHAIN`. Every validation error is a fail; there is no warning level. Because the page fetch uses the same kind of verification, in practice you will more often see the failed report described above than this finding in red.

> **Check: Certificate expiry.** When the certificate expires, every browser shows a full-page warning and most visitors leave. Automatic renewals fail silently more often than you would expect.
>
> 1. Renew the certificate now; with Let's Encrypt, run certbot renew and check the auto-renew job's logs.
> 2. Add a monitor so you get an email 14 days before the next expiry.

The expiry finding warns when fewer than 30 days are left and fails below 7. A second handshake tries TLS 1.0 and 1.1 for the protocol finding, which [turning off TLS 1.0 and 1.1](https://getreport.app/guides/tls-1-0-and-1-1-turning-legacy-protocols-off) covers.

> **Check: The site is served over HTTPS.** Browsers label HTTP pages "Not secure" and Google uses HTTPS as a small ranking signal. Anyone on the network can read or alter what visitors see.
>
> 1. Get a certificate (Let's Encrypt is free) and enable HTTPS on your host.
> 2. Redirect every http:// URL to https:// with a 301.

![The SSL / TLS check run on a local copy of the example shop served over plain HTTP: security score 63, the failed finding "The site is served over HTTP, not HTTPS", an informational HSTS preload line and one passed check](https://getreport.app/guides/img/certificate-chain-errors-and-intermediate-certificates/tls.webp "Over plain HTTP there is no certificate to read, so the chain, expiry and protocol findings are left out and only the HTTPS finding remains.")

## Step by step

### 1. Look at the chain the server sends

From any machine with OpenSSL (macOS and Linux have it; on Windows, Git Bash includes it):

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

`-servername` matters: without it, a server hosting several sites may send a different site's certificate. In the output, find the "Certificate chain" block and the last line. A broken chain looks like this (abridged):

```text
Certificate chain
 0 s:CN = www.example.com
   i:C = US, O = Let's Encrypt, CN = R11
---
Verify return code: 21 (unable to verify the first certificate)
```

Only one certificate, number 0, is sent. A healthy chain has two or more:

```text
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
---
Verify return code: 0 (ok)
```

Read it as a ladder: each certificate's issuer (`i:`) is the subject (`s:`) of the next one down, and the last issuer is a root your system trusts. curl gives a shorter verdict:

```bash
curl -sSI https://www.example.com/ -o /dev/null && echo "chain ok"
# Broken chain: curl: (60) SSL certificate problem: unable to get local issuer certificate
```

### 2. Match the error to the cause

| Message | Cause | Fix |
| --- | --- | --- |
| unable to get local issuer certificate / unable to verify the first certificate (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`) | The intermediate is missing | Install the full chain file |
| certificate has expired (`CERT_HAS_EXPIRED`) | The leaf, or an intermediate in an old bundle, has expired | Renew; rebuild the chain from the CA's current files |
| Hostname/IP does not match certificate's altnames (`ERR_TLS_CERT_ALTNAME_INVALID`) | The certificate does not list this name, typically `www` covered and the bare domain not, or the reverse | Reissue with both names |
| self-signed certificate in certificate chain (`SELF_SIGNED_CERT_IN_CHAIN`) | The chain ends at a root the client does not trust: a private or company CA, or an old root that was removed | Use a certificate from a public CA, or the CA's current chain |
| self-signed certificate (`DEPTH_ZERO_SELF_SIGNED_CERT`) | A self-signed leaf, often a hosting panel's placeholder | Issue a real certificate for the domain |

Intermediates in the wrong order are usually tolerated by current browsers and OpenSSL, but some older clients fail on them, so keep the order right anyway: leaf first, then each intermediate. Sending the root as well does not break validation; it is just bytes every visitor downloads for nothing.

### 3. Build the right file

If your certificate authority gave you separate files, concatenate them, leaf first:

```bash
cat www.example.com.crt intermediate.crt > fullchain.pem
# Check the result against your system's roots before installing it
openssl verify -untrusted intermediate.crt www.example.com.crt
# Expected: www.example.com.crt: OK
```

Some authorities ship a "CA bundle" with two intermediates; append it whole, in the order it came. With Let's Encrypt and certbot, the file already exists: `/etc/letsencrypt/live/<domain>/fullchain.pem` is the leaf plus intermediates, `cert.pem` is the leaf alone, `chain.pem` the intermediates alone.

### 4. Install it on the server

**nginx** (in the `server` block for port 443):

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

Test and reload with `nginx -t && systemctl reload nginx`. nginx has no separate chain directive; the chain lives in the `ssl_certificate` file.

**Apache 2.4.8 and later** (in the `<VirtualHost *:443>` block):

```apache
SSLCertificateFile    /etc/letsencrypt/live/www.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.example.com/privkey.pem
```

On older Apache versions, `SSLCertificateFile` takes the leaf and `SSLCertificateChainFile` the intermediates (`chain.pem`). Check your version with `apachectl -v`, then `apachectl configtest && systemctl reload apache2`.

**[Caddy](https://getreport.app/guides/caddy-the-two-line-https-server) and Traefik** obtain certificates automatically and always send the full chain, so a chain error is rare with them. If you load your own certificate into Caddy with `tls cert.pem key.pem`, the certificate file must contain the chain.

**cPanel**: SSL/TLS → Manage SSL sites has a "Certificate Authority Bundle (CABUNDLE)" field under the certificate. Paste the intermediates there. AutoSSL fills it for you.

### 5. Re-test from outside

Run the `openssl s_client` command again, from a machine other than the server, and look for "Verify return code: 0 (ok)" and at least two certificates in the chain. Then run the SSL / TLS check. If the server has several IP addresses (IPv4 and IPv6, several nodes behind DNS), a node you did not update will still serve the old chain; test each address.

## Platform notes

### Let's Encrypt

Point the server at `fullchain.pem`, never `cert.pem`, and never copy the intermediate by hand. Let's Encrypt changes its intermediates from time to time (R3 was replaced by R10 and R11 in 2024), and certbot writes the current chain on every renewal; a hand-copied intermediate goes stale. History has one famous chain failure: on 30 September 2021 the old DST Root CA X3 expired, and clients with outdated trust logic, such as OpenSSL 1.0.2, failed on perfectly valid Let's Encrypt certificates. Chains now end at ISRG Root X1, which Android trusts from version 7.1.1 onwards. certbot's `--preferred-chain` option picks between alternative chains when Let's Encrypt offers more than one.

### Cloudflare and other CDNs

Visitors see the CDN's edge certificate, so getReport and every other outside tool check that one. The chain between the CDN and your origin is invisible from outside. With Cloudflare's "Flexible" mode the edge talks to your origin over plain HTTP, and with "Full" it accepts any origin certificate, so an expired or broken origin chain stays hidden until you switch to "Full (strict)" or bypass the CDN. Use Full (strict); a Cloudflare Origin CA certificate on the origin satisfies it, though it is trusted only by Cloudflare and must never face visitors directly.

### Load balancers

AWS Application Load Balancers use certificates from AWS Certificate Manager. Certificates issued by ACM include the chain automatically; an imported certificate needs the intermediates pasted into the "Certificate chain" field. Other load balancers and hosting panels have a similar field, often labelled "CA bundle" or "chain".

## Verify

- `openssl s_client … -servername …` shows two or more certificates and "Verify return code: 0 (ok)" from outside your network.
- `curl -sSI https://www.example.com/` completes without error 60.
- A getReport report on the site completes, and the SSL / TLS check shows "The TLS certificate chain validates" with the issuer in the technical line, plus the expiry finding with more than 30 days left.
- The app, webhook or partner integration that failed now connects.

Certificate lifetimes are getting shorter: the CA/Browser Forum has agreed a step-by-step cut in the maximum lifetime, from 398 days to 200 days in March 2026 and down to 47 days by 2029, so renewal has to be automatic. [Certificate expiry: automate it, then monitor it](https://getreport.app/guides/certificate-expiry-automate-it-then-monitor-it) covers the renewal job; until getReport's monitoring unlock is funded, run the SSL / TLS check after each renewal.

## Common mistakes

- **Installing `cert.pem` instead of `fullchain.pem`.** Symptom: fine in Chrome, error 60 in curl, failed report in getReport. Point the server at `fullchain.pem` and reload.
- **Including the root in the chain.** Harmless for validation, wasted bytes on every new connection, and a sign the bundle was assembled by hand. Leave it out.
- **Testing only in a desktop browser.** It can download or remember the intermediate. Test with `openssl s_client` or curl, from outside.
- **An old CA bundle on the server.** An intermediate copied years ago expires or is replaced while the leaf renews fine. Rebuild the chain from the authority's current files, or let the ACME client do it.
- **Cloudflare Flexible hiding a broken origin.** Everything looks fine until the mode changes or the CDN is bypassed. Use Full (strict) and fix the origin chain now.
