TLS 1.3 Handshake Explained: 0-RTT, Certificate Validation, and Debugging Failed Handshakes
Every HTTPS connection starts with a handshake โ a choreographed exchange where client and server agree on encryption parameters, authenticate each other, and derive session keys. TLS 1.3 (RFC 8446, published 2018) cut the handshake down to a single round trip and removed a decade's worth of legacy cruft. Yet most developers still treat it as a black box, which makes debugging failures like SSL_ERROR_HANDSHAKE_FAILURE or certificate verify failed an exercise in guesswork.
This article opens the box: the exact message flow, what 0-RTT resumption does, how certificate chain validation works, and the commands to diagnose real failures.
The Problem TLS Solves
HTTP is plaintext. Anyone between you and the server โ a coffee shop Wi-Fi operator, a compromised router, an ISP โ can read and modify traffic. TLS adds three properties:
- Confidentiality: traffic is encrypted; eavesdroppers see only ciphertext.
- Integrity: each record is authenticated; tampering is detected.
- Authentication: the client verifies it's talking to the real server (via certificates), not an impostor.
The handshake is the protocol that bootstraps all three from nothing but a TCP connection.
TLS 1.3 Handshake: The 1-RTT Flow
TLS 1.3 reduced the full handshake to one round trip (down from two in TLS 1.2). Here's the message sequence:
Client Server
| |
|---- ClientHello ----------------------------------->|
| (key_share, supported_versions, |
| signature_algorithms, server_name) |
| |
|<--- ServerHello ------------------------------------|
| (key_share, selected version/cipher) |
|<--- EncryptedExtensions ----------------------------|
|<--- Certificate -----------------------------------|
|<--- CertificateVerify ------------------------------|
|<--- Finished --------------------------------------|
| |
|---- Finished ------------------------------------->|
| |
|<================ Application Data ================>|
Step 1: ClientHello
The client sends its capabilities and a guess at the key exchange. The critical extension is key_share: the client generates an ephemeral keypair (typically X25519 or P-256) and sends the public key immediately, rather than waiting for the server to pick parameters first. This optimistic key share is what eliminates a round trip.
Other key extensions:
supported_versions: lists TLS 1.3 (and possibly 1.2 for fallback)signature_algorithms: which signature schemes the client accepts for certificate verificationserver_name(SNI): the hostname the client wants โ essential for servers hosting multiple sites on one IP
Step 2: ServerHello + EncryptedExtensions
The server picks a mutually supported cipher suite and responds with its own key_share. At this point, both sides can independently compute the same shared secret using Diffie-Hellman โ no further key transport needed. Everything after ServerHello is encrypted.
EncryptedExtensions carries negotiated parameters (like ALPN for HTTP/2 vs HTTP/1.1 selection).
Step 3: Certificate + CertificateVerify + Finished
The server sends its certificate chain, then proves possession of the private key by signing the handshake transcript (CertificateVerify). Finished is a MAC over the entire handshake, confirming no tampering occurred.
The client then sends its own Finished, and application data flows.
What TLS 1.3 Removed
TLS 1.3 deliberately dropped insecure legacy features that caused real-world attacks:
| Removed in 1.3 | Why |
|---|---|
| RSA key transport | No forward secrecy โ if the private key leaks later, all past traffic decrypts |
| CBC-mode cipher suites | Source of Lucky13 and padding oracle attacks |
| RC4, 3DES | Broken or deprecated ciphers |
| Renegotiation | Source of the 2009 renegotiation attack |
| Compression | CRIME attack vector |
The result: fewer cipher suites (only 5, all AEAD), less negotiation surface, fewer ways to misconfigure.
0-RTT Resumption: The Speedup and Its Cost
For repeat connections, TLS 1.3 offers 0-RTT: the client can send application data in its first flight, alongside the ClientHello, using a pre-shared key (PSK) from a previous session.
Client Server
| |
|---- ClientHello + early_application_data --------->|
| |
|<--- ServerHello ... -------------------------------|
This saves a full round trip โ significant on high-latency mobile networks. But there's a catch: 0-RTT data is replayable. An attacker who captures the client's first flight can resend it, and the server can't distinguish a replay from the original. RFC 8446 explicitly warns: never use 0-RTT for non-idempotent requests. A GET /profile is fine; a POST /transfer-money is not.
Most servers disable 0-RTT by default for this reason. Cloudflare, for example, only enables it for safe methods.
Certificate Chain Validation
The handshake's authentication rests on X.509 certificates. Here's what the client actually verifies:
- Chain building: starting from the server's leaf certificate, follow
issuerโsubjectlinks up to a trusted root in the client's trust store. - Signature verification: each certificate's signature is verified using its issuer's public key.
- Validity period:
notBeforeโค now โคnotAfter. - Hostname matching: the leaf's Subject Alternative Name (SAN) must match the requested hostname (SNI value).
- Revocation checking: via OCSP stapling (server provides a fresh OCSP response) or CRL.
- Key usage constraints: the certificate must permit server authentication (
extendedKeyUsage: serverAuth).
A failure at any step aborts the handshake. The most common real-world failures:
| Error | Cause |
|---|---|
certificate verify failed: unable to get local issuer certificate |
Incomplete chain โ server didn't send intermediates |
certificate has expired |
Expired cert (or client clock wrong) |
hostname mismatch |
SAN doesn't cover the requested domain |
self-signed certificate |
Chain doesn't lead to a trusted root |
Debugging Handshakes with OpenSSL
When a handshake fails, openssl s_client is the fastest diagnostic:
# Full handshake with certificate chain display
openssl s_client -connect example.com:443 -servername example.com -showcerts
# Check protocol version and cipher negotiated
openssl s_client -connect example.com:443 -tls1_3 2>/dev/null | grep -E "Protocol|Cipher"
# Verify certificate chain against system trust store
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates
# Test with a specific cipher suite
openssl s_client -connect example.com:443 \
-ciphersuites TLS_AES_256_GCM_SHA384
A typical failure looks like:
$ openssl s_client -connect expired.badssl.com:443
...
Verify return code: 10 (certificate has expired)
---
SSL handshake has read 0 bytes and written 0 bytes
In Python, certificate validation errors surface clearly:
import ssl, socket
ctx = ssl.create_default_context()
try:
with socket.create_connection(("example.com", 443)) as sock:
with ctx.wrap_socket(sock, server_hostname="example.com") as ssock:
cert = ssock.getpeercert()
print("Cipher:", ssock.cipher())
print("TLS version:", ssock.version())
print("Subject:", dict(x[0] for x in cert["subject"]))
except ssl.SSLCertVerificationError as e:
print(f"Certificate failed verification: {e.verify_message}")
except ssl.SSLError as e:
print(f"Handshake failed: {e}")
Try It
Debugging TLS issues often starts with inspecting what's actually on the wire โ encoded tokens, headers, and certificates. DailyToolbox's free JWT decoder runs entirely in your browser: paste a token and see its header, payload, and signature segments decoded instantly, including the base64url encoding that TLS-adjacent protocols rely on. For URL-related debugging, the URL encoder handles the percent-encoding that trips up so many hand-rolled implementations.
FAQ
Why does TLS 1.3 only need 1 round trip vs 1.2's 2?
The client's key_share extension sends an ephemeral public key in the very first message. In TLS 1.2, the client had to wait for the server's ServerHello to learn which key exchange parameters to use. The optimistic key share guesses the server's preferred group (and includes multiple shares as fallback), eliminating the extra round trip.
Is 0-RTT safe to enable?
Only for idempotent requests. 0-RTT early data lacks replay protection โ an attacker can capture and resend it. RFC 8446 Section 8 explicitly restricts 0-RTT to safe operations. Most production deployments either disable it or limit it to GET requests.
What's the difference between a certificate chain and a single certificate?
A single certificate is just the leaf (your domain's cert). The chain includes intermediate CA certificates linking the leaf to a trusted root. Servers must send the full chain (leaf + intermediates); if intermediates are missing, clients that don't have them cached will fail with "unable to get local issuer certificate" โ one of the most common TLS misconfigurations.
Why was RSA key exchange removed in TLS 1.3?
RSA key transport encrypts the session key with the server's long-term public key. If that private key is ever compromised, an attacker can decrypt all previously recorded traffic. Ephemeral Diffie-Hellman (used exclusively in 1.3) generates fresh keys per connection, providing forward secrecy: past sessions stay secure even if long-term keys leak.