Skip to content
Go back

Why Your TLS Certificate Isn't Trusted

By SumGuy 4 min read
Why Your TLS Certificate Isn't Trusted

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:

  1. Root CA (self-signed) — Verisign, DigiCert, etc. Baked into every OS and browser.
  2. Intermediate CA (signed by root) — The authority that signed your cert.
  3. 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:

Terminal window
$ openssl s_client -connect example.com:443 -servername example.com

Output 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 X1

That’s good. You have:

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 only

Use fullchain.pem, not cert.pem.

If you’re running nginx:

nginx.conf
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:

apache2/sites-available/example.com.conf
<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 cert
3. Do NOT include the root cert
build_bundle.sh
cat /path/to/your/server.crt /path/to/intermediate.crt > /etc/ssl/certs/fullchain.pem

If 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:

Terminal window
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

This 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:

  1. Add the cert to the client’s trusted store
  2. Disable certificate verification (dev-only, dangerous)
  3. Use Let’s Encrypt instead (honestly, just do this)

To check a self-signed cert:

Terminal window
$ openssl x509 -in cert.pem -text -noout

Look for:

Subject: CN = example.local
Issuer: CN = example.local

Same issuer and subject? Self-signed. That’s fine for internal use.

Quick Trust Validation Script

validate_chain.sh
#!/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:

Terminal window
$ bash validate_chain.sh example.com 443

You’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:

Terminal window
$ 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:

Terminal window
$ openssl s_client -connect example.com:443 -servername example.com

Look 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
awk for Log Parsing: 5 Patterns You'll Actually Use
Next Post
jq One-Liners Every Sysadmin Needs

Related Posts