Debugging TLS issues is something I do more than I’d like. Browser errors are often vague, and getting to the actual root cause usually requires some manual inspection with OpenSSL. These are the techniques I actually use.
Connecting to a Server and Inspecting the Certificate
The most basic thing — see what certificate a server is actually presenting:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null
This shows the full certificate chain, SSL session info, and connection details. The </dev/null prevents s_client from waiting for stdin input.
For a specific IP (useful when testing before DNS cutover):
openssl s_client -connect 93.184.216.34:443 -servername example.com </dev/null 2>/dev/null
The -servername flag sends the SNI header, which tells the server which certificate to present when multiple sites share an IP.
Checking Expiry Dates
For a file:
openssl x509 -noout -dates -in certificate.pem
Output:
notBefore=Jan 1 00:00:00 2024 GMT
notAfter=Jan 1 00:00:00 2025 GMT
For a live server:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -dates
I use this in monitoring scripts constantly. Simple, fast, no special tools needed.
Days until expiry (with math):
expiry=$(echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 ))
echo "$days days until expiry"
Verifying a Certificate Chain
A certificate chain problem is one of the most common SSL errors in practice. The server needs to present the full chain: leaf certificate + intermediate CA(s). If the intermediate is missing, some clients fail (depending on whether they’ve cached it from previous connections).
Check what a server is actually sending:
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -text | grep "Issuer\|Subject"
This shows each certificate in the chain with its issuer. The chain should connect: your cert is issued by intermediate CA X, and intermediate CA X is issued by root CA Y.
More detailed chain inspection:
openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null
-showcerts shows every certificate in the chain. Count them — for most Let’s Encrypt certificates you’ll see 2 (leaf + intermediate). For some CAs, 3 (leaf + 2 intermediates).
Verify a chain against a CA bundle:
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt certificate.pem
Or verify with an explicit intermediate:
openssl verify -CAfile root_ca.pem -untrusted intermediate.pem server.pem
If verification fails, the output tells you why: unable to get local issuer certificate usually means the intermediate is missing or the CA isn’t trusted.
Checking That Key and Certificate Match
A mismatch between the private key and certificate causes TLS to fail completely. This happens when you have multiple certs/keys and accidentally pair the wrong ones.
openssl x509 -noout -modulus -in certificate.pem | md5sum
openssl rsa -noout -modulus -in private.key | md5sum
If both MD5 hashes are identical, the key and certificate match. If they differ, you have the wrong key.
For ECDSA:
openssl x509 -noout -pubkey -in certificate.pem | md5sum
openssl ec -pubout -in private.key 2>/dev/null | md5sum
Testing TLS Handshake with Specific Protocol/Ciphers
Force a specific TLS version:
openssl s_client -connect example.com:443 -servername example.com -tls1_2 2>&1
openssl s_client -connect example.com:443 -servername example.com -tls1_3 2>&1
If -tls1_2 fails with “no protocols available”, the server only supports TLS 1.3 (or older). If -tls1_3 fails, TLS 1.3 isn’t supported.
Test a specific cipher:
openssl s_client -connect example.com:443 \
-cipher ECDHE-RSA-AES128-GCM-SHA256 \
-servername example.com 2>&1 | head -20
Useful for verifying which ciphers are actually accepted or for debugging compatibility with older clients.
Checking OCSP Stapling
OCSP stapling is when the server includes a pre-fetched OCSP response in the TLS handshake, so clients don’t need to query the OCSP server separately.
openssl s_client -connect example.com:443 \
-servername example.com \
-status 2>/dev/null < /dev/null | grep -A3 "OCSP response"
If OCSP stapling is configured and working, you’ll see OCSP Response Status: successful (0x0) and a certificate status of good.
If you see “OCSP response: no response sent”, stapling isn’t configured or isn’t working. The OCSP stapling guide for Nginx covers setup details.
Checking Subject Alternative Names
Modern certificates use SANs rather than Common Name for domain validation. Check what a cert covers:
openssl x509 -noout -text -in certificate.pem | grep -A1 "Subject Alternative Name"
Or for a live server:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
Output like DNS:example.com, DNS:www.example.com, DNS:*.example.com shows exactly what the certificate covers. If the hostname you’re trying to reach isn’t in this list, you’ll get a name mismatch error.
Common Error Messages and What They Mean
SSL_ERROR_RX_RECORD_TOO_LONG — the server returned plaintext when the client expected TLS. Usually means you connected to the HTTP port instead of HTTPS.
ssl handshake failure — various causes. Check that you’re using a protocol version the server supports. Try with -tls1_2 or -tls1_3 explicitly.
certificate verify failed (unable to get local issuer certificate) — missing intermediate certificate. The server isn’t sending the full chain, or your trust store is outdated.
certificate verify failed (certificate has expired) — the certificate is past its notAfter date.
hostname mismatch — the hostname you connected to isn’t in the certificate’s CN or SAN list.
no peer certificate available — the server closed the connection before presenting a cert. Check if the port is actually serving TLS.
Quick Script: Check Multiple Sites
When I need to verify certificates across several servers:
#!/bin/bash
DOMAINS="example.com api.example.com staging.example.com"
for domain in $DOMAINS; do
expiry=$(echo | openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$expiry" ]; then
echo "$domain: FAILED TO CONNECT"
continue
fi
days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 ))
if [ "$days" -lt 14 ]; then
echo "$domain: WARNING - expires in $days days ($expiry)"
else
echo "$domain: OK - expires in $days days"
fi
done
This is a lightweight version of what certificate monitoring tools do. For something more robust and with alerting, dedicated monitoring is worth setting up. But for quick checks across a handful of domains, this script does the job.
For more OpenSSL commands covering the full range of day-to-day certificate work, the OpenSSL practical cheatsheet covers certificate inspection, CSR generation, format conversion, and more.