Your site loads fine for you. Your certificate looks valid. But users in some regions get a “This connection is not private” warning. Or your API client silently fails with a certificate verification error. The problem isn’t your cert — it’s the chain.
I spent a Friday afternoon debugging this once. The cert was good, it was installed correctly, but nobody in their right mind could verify it. Turns out I’d installed only the leaf cert without the intermediate. The client browser has the root CA cached, but not the intermediate CA, so the chain is broken.
Here’s how to actually diagnose this.
The Chain Concept (30-Second Version)
Your TLS setup has three layers:
- Root CA (self-signed) — Verisign, DigiCert, etc. Baked into every OS and browser.
- Intermediate CA (signed by root) — The authority that signed your cert.
- Leaf (Server) Cert (signed by intermediate) — The one for
example.com.
When a client connects, the server sends layers 2 and 3. The client already has layer 1. The client verifies: leaf signed by intermediate? Yes. Intermediate signed by root? Yes (because it has the root cached). Trust established.
If you only send the leaf cert (layer 3), the client says: “I see the leaf, but where’s the intermediate? I don’t know who signed this.” Connection fails.
Diagnose with openssl s_client
Connect to your server and see what it’s actually sending:
$ openssl s_client -connect example.com:443 -servername example.comOutput is verbose. Look for the certificate chain section:
Certificate chain 0 s:CN=example.com i:C=US, O=Let's Encrypt, CN=R3 issuer: R3 1 s:C=US, O=Let's Encrypt, CN=R3 i:C=US, O=Internet Security Research Group, CN=ISRG Root X1 issuer: ISRG Root X1That’s good. You have:
- Cert 0: your domain cert (issued by R3)
- Cert 1: the intermediate (R3, issued by root)
The client browser gets both, verifies the chain, and trusts it.
If you only see Cert 0 and the issuer info is missing or pointing nowhere, you’re missing the intermediate.
The Fix: Build the Full Chain
Let’s Encrypt gives you this:
/etc/letsencrypt/live/example.com/├── privkey.pem ← Your private key├── fullchain.pem ← CORRECT: leaf + intermediate└── cert.pem ← WRONG: leaf onlyUse fullchain.pem, not cert.pem.
If you’re running nginx:
server { listen 443 ssl; server_name example.com;
# WRONG: # ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
# CORRECT: ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;}Apache:
<VirtualHost *:443> # WRONG: # SSLCertificateFile /etc/letsencrypt/live/example.com/cert.pem
# CORRECT: SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem SSLCertificateChainFile /etc/letsencrypt/live/example.com/chain.pem</Apache>Restart the service and test again with openssl s_client.
Wrong Order in Bundle
Sometimes you have all three pieces but in the wrong order. If you’re manually building a bundle, the order is critical:
1. Your server cert (leaf)2. Intermediate cert3. Do NOT include the root certcat /path/to/your/server.crt /path/to/intermediate.crt > /etc/ssl/certs/fullchain.pemIf you accidentally put the intermediate first, the client reads the chain wrong and trust fails.
Self-Signed Certs (No Chain Required)
If you’re using a self-signed cert for testing or internal services:
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodesThis creates both cert and key in one go. The cert is self-signed, so it doesn’t need an intermediate. But clients won’t trust it unless you:
- Add the cert to the client’s trusted store
- Disable certificate verification (dev-only, dangerous)
- Use Let’s Encrypt instead (honestly, just do this)
To check a self-signed cert:
$ openssl x509 -in cert.pem -text -nooutLook for:
Subject: CN = example.localIssuer: CN = example.localSame issuer and subject? Self-signed. That’s fine for internal use.
Quick Trust Validation Script
#!/bin/bash
DOMAIN="${1:-example.com}"PORT="${2:-443}"
echo "Testing $DOMAIN:$PORT..."openssl s_client -connect "$DOMAIN:$PORT" -servername "$DOMAIN" 2>/dev/null | \ openssl x509 -text -noout | grep -A5 "Subject:\|Issuer:"
echo ""echo "Full chain:"openssl s_client -showcerts -connect "$DOMAIN:$PORT" -servername "$DOMAIN" < /dev/null 2>/dev/null | \ grep -E "^(subject|issuer)="Run it:
$ bash validate_chain.sh example.com 443You’ll see the leaf cert info and then the full chain. Make sure you see both the leaf and intermediate.
The Nuclear Option: Certificate Transparency
For ultimate verification, check Certificate Transparency logs:
$ openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -text | grep -A2 "CT Precertificate"If CT logs show the cert was publicly issued and recognized, you’re good. If nothing appears, you might have a problem.
The One Thing: Test After Changes
Every time you update your certificate setup, run:
$ openssl s_client -connect example.com:443 -servername example.comLook for “Verify return code: 0 (ok)” at the bottom. If you see anything else, your chain is broken.
That’s the litmus test. Do it before you go home.