Diagnose TLS failures

Diagnose a missing intermediate

Identify an incomplete server chain and verify the same leaf with the required intermediate supplied.

This is the sneakiest chain failure, because it hides. A server presents a correct leaf but forgets the intermediate needed to reach a trusted root. Some clients don’t notice, because they cached that intermediate from an earlier visit to a different site. Browsers do this all the time. So the site works in Chrome, works on your laptop, and fails in curl, in CI, and on a brand new phone.

The tell is exactly that pattern: a TLS failure that only some clients see.

Let’s reproduce it. Point the lab server back at app.crt alone instead of app-fullchain.crt, restart it, and inspect the chain:

openssl s_client -connect 127.0.0.1:8443 -servername app.lab.test -showcerts -CAfile lab-ca.crt </dev/null

Two parts of the output matter. First, count the certificates in the Certificate chain section:

verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
Certificate chain
 0 s:CN=app.lab.test
   i:CN=Practical TLS Lab Intermediate CA
...
Verify return code: 21 (unable to verify the first certificate)

One certificate, and its issuer is an intermediate that never arrived. Error 20 is OpenSSL saying “I have the leaf, but nothing the server sent and nothing in my trust store signs it”. Error 21 follows from it: the first certificate can’t be verified. Both codes point at the same problem, a gap between the leaf and your root.

Second, prove the leaf itself is fine. Save certificate 0 from the server as presented.crt, then verify it with the intermediate supplied by hand:

openssl s_client -connect 127.0.0.1:8443 -servername app.lab.test </dev/null 2>/dev/null | openssl x509 -out presented.crt
openssl verify -CAfile lab-ca.crt -untrusted lab-intermediate.crt presented.crt
# presented.crt: OK

-untrusted provides certificates OpenSSL may use to build the path, without treating them as trust anchors. If this prints OK, the diagnosis is settled. The leaf is good. The server’s configuration is incomplete.

The fix belongs on the server, once. Compare what the server sends with the chain it should send, load the full-chain file from the previous module, and retest from a clean client with no cached state. curl is a good clean client: it caches nothing between runs.

Don’t add the leaf to every client’s trust store. That “fix” multiplies across every client, breaks again at the next renewal, and trains people to click through trust prompts. Repair the server chain and every client heals at the same moment.

Lesson completed