TLS Handshake Failures: Reading the Error the Server Actually Sent
handshake failure, protocol version, unknown ca and bad certificate all mean different things. Here is how to get the real reason out of a connection that dies in the first two packets.
The short answer
curl: (35) error:0A000410:SSL routines::sslv3 alert handshake failure
The client message is almost never the real reason. Get the alert the server sent:
# the single most useful command, shows the whole handshake
openssl s_client -connect api.example.com:443 -servername api.example.com
# what the server will actually negotiate
openssl s_client -connect api.example.com:443 -tls1_2 -servername api.example.com
openssl s_client -connect api.example.com:443 -tls1_3 -servername api.example.com
# the chain as served, which is the usual culprit
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
Read the last few lines. Verify return code: 21 (unable to verify the first certificate) means an incomplete chain. no protocol available means version mismatch. unknown ca means your trust store, not their certificate.
Tested on OpenSSL 3.4, curl 8.11, Node 22.14.
The handshake, briefly, because the failure point matters
A TLS 1.3 handshake is two round trips. The client sends ClientHello with its supported versions, cipher suites, and the SNI hostname. The server picks one of each, sends its certificate, and both sides derive keys.
Failures happen at exactly three points, and the alert tells you which:
| Alert | Point of failure | Fault usually lies with |
|---|---|---|
protocol version |
Version negotiation | Client too old or server too strict |
handshake failure |
Cipher or parameter negotiation | No overlap in cipher suites, or missing SNI |
unknown ca |
Certificate verification | Client trust store or incomplete chain |
bad certificate |
Certificate validation | Expiry, hostname mismatch |
certificate expired |
Validity window | Expiry, or clock skew |
The causes, ranked by how often I hit them
1. Incomplete certificate chain
This is the most common by a wide margin, and the cruellest, because it works in your browser and fails everywhere else.
A certificate is issued by an intermediate, and the intermediate is issued by a root. Your trust store has the root. The server must send the intermediate, because the client has no way to fetch it reliably.
Browsers hide this. They cache intermediates from previous sites and some will fetch a missing one via the AIA extension. curl, Java, Python and Go do not. So the site "works" for you and fails for every API client.
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
One certificate returned means leaf only, which is broken. You want two or three.
The fix is to serve the full chain. In nginx, ssl_certificate must point at a file containing leaf then intermediates in that order, not the leaf alone:
cat leaf.crt intermediate.crt > fullchain.crt
Let's Encrypt names this correctly: use fullchain.pem, never cert.pem.
2. Missing SNI
One IP serves many hostnames. The server chooses a certificate based on the SNI extension in ClientHello. Omit it and you get whatever the default virtual host serves, which will then fail hostname verification, or the server rejects the handshake outright.
# fails
openssl s_client -connect 203.0.113.10:443
# works
openssl s_client -connect 203.0.113.10:443 -servername api.example.com
In code, this happens when you connect by IP and pass the hostname separately, or when a proxy rewrites the Host header but not SNI. Anything connecting to an IP address needs SNI set explicitly.
3. Protocol version floor
TLS 1.0 and 1.1 are disabled nearly everywhere now. A client pinned to them gets protocol version or an outright connection reset.
for v in tls1 tls1_1 tls1_2 tls1_3; do
printf "%-8s " "$v"
openssl s_client -connect example.com:443 -servername example.com -$v </dev/null >/dev/null 2>&1 \
&& echo "supported" || echo "rejected"
done
OpenSSL 3.x also enforces a default security level that rejects certificates signed with SHA-1 and RSA keys under 2048 bits. Connecting to old internal appliances fails for this reason even though the appliance is configured correctly for its era. -cipher DEFAULT@SECLEVEL=1 proves that is the cause, but treat it as a diagnosis rather than a fix.
4. Clock skew
Certificates have notBefore and notAfter. A host whose clock is wrong rejects perfectly valid certificates, and the error says the certificate is expired when it is not.
date -u
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates
This is the exact failure we hit when restoring microVM snapshots at Krova Cloud: a guest resumes believing it is whenever the snapshot was taken, so TLS validation fails against certificates that are not yet valid. The fix is making the guest resync its clock on resume, and it is one of the correctness problems the platform now handles for you rather than leaving to the workload.
5. Mutual TLS with no client certificate
If the server requests a client certificate and gets none, you see a generic handshake failure on the client and something more specific in the server log. The asymmetry is deliberate; the server does not tell an unauthenticated client why it was refused.
openssl s_client -connect api.example.com:443 -servername api.example.com \
-cert client.crt -key client.key
Look for Acceptable client certificate CA names in the s_client output. If that list is present, the server wants mTLS.
Getting the truth out of a language runtime
Each runtime hides the alert differently. These expose it:
# Node
NODE_DEBUG=tls node app.js
# Python requests / urllib3
python3 -W always -c "import ssl; print(ssl.OPENSSL_VERSION)"
SSLKEYLOGFILE=/tmp/keys.log python3 app.py
# Java
java -Djavax.net.debug=ssl:handshake -jar app.jar
# Go
GODEBUG=tls13=1 go run main.go
If nothing else works, capture the handshake itself. It is unencrypted up to the point of failure:
tcpdump -i any -n -s0 -w /tmp/tls.pcap 'host api.example.com and port 443'
The ClientHello, ServerHello and the alert are all readable in Wireshark, which settles arguments about who rejected whom.
Prevention
- Test with
curl, not a browser. Browsers repair broken chains and hide the problem you are shipping. - Monitor expiry as a metric with at least thirty days of warning, and alert on the certificate your server actually serves rather than the one in your config directory.
- Serve
fullchain.pem. Check after every renewal, because renewal scripts that write the leaf alone are a recurring source of outages. - Keep clocks synchronised with NTP and treat skew as an incident class of its own.
- When you pin certificates, pin to the intermediate rather than the leaf, otherwise every renewal is an outage.