The TLS Handshake — What Happens When You Type HTTPS
Your API returns 502. The load balancer logs say:
SSL handshake failed. Your on-call partner says "just restart nginx." But the error comes back.You need to understand what the TLS handshake IS — what messages are exchanged, what can go wrong at each step, and what "handshake failed" actually means — before you can fix what broke.
This lesson walks through every step of the TLS handshake, from the first byte to encrypted data. By the end, you will be able to read
openssl s_clientoutput like a book and diagnose handshake failures in minutes instead of hours.
Part 1: What the Handshake Accomplishes
Before a single byte of HTTP data is encrypted, the client and server must agree on three things:
- Which cryptographic algorithms to use (cipher suite negotiation)
- That the server is who it claims to be (certificate authentication)
- A shared secret key for encrypting data (key exchange)
All of this happens before the first HTTP request. The handshake is overhead — pure latency with no application data — so speed matters enormously. Every extra round trip adds real milliseconds to every new connection.
The TLS handshake adds 1-2 round trips before any HTTP data can flow. On a connection with 100ms round-trip time, that is 100-200ms of latency added to the first request. This is why TLS 1.3 reduced the handshake to 1 round trip, and why HTTP/2 and HTTP/3 aggressively reuse connections to avoid repeated handshakes.
Part 2: The TLS 1.2 Handshake — Step by Step
TLS 1.2 is still widely deployed and understanding it makes TLS 1.3 improvements obvious. The full handshake requires 2 round trips.
Round Trip 1: Hello and Certificate
Client sends ClientHello:
- Supported TLS versions (e.g., TLS 1.0, 1.1, 1.2)
- List of supported cipher suites (ordered by preference)
- A random 32-byte value (Client Random)
- Supported compression methods
- Extensions (SNI hostname, supported groups, signature algorithms)
ClientHello
Version: TLS 1.2
Random: 7b2e5f... (32 bytes)
Cipher Suites (17 suites):
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
...
Extensions:
server_name: devopsbeast.com
supported_groups: x25519, secp256r1
signature_algorithms: ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256
The SNI (Server Name Indication) extension in ClientHello is how a single IP address can serve TLS certificates for multiple domains. The client tells the server which hostname it wants BEFORE the certificate is selected. Without SNI, virtual hosting over HTTPS would not work. Note that SNI is sent in plaintext — encrypted SNI (ECH) is still being standardized.
Server responds with ServerHello + Certificate + ServerKeyExchange + ServerHelloDone:
- ServerHello: chosen TLS version, chosen cipher suite, Server Random (32 bytes)
- Certificate: the server's X.509 certificate chain (leaf + intermediates)
- ServerKeyExchange: the server's ECDHE public key (for key exchange)
- ServerHelloDone: signals the server is finished
ServerHello
Version: TLS 1.2
Random: 4a1c8d... (32 bytes)
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
Certificate
Subject: CN=devopsbeast.com
Issuer: CN=R3, O=Let's Encrypt
Validity: 2026-03-01 to 2026-05-30
ServerKeyExchange
Named Curve: x25519
Public Key: 3e7f2a... (32 bytes)
ServerHelloDone
Round Trip 2: Key Exchange and Finish
Client sends ClientKeyExchange + ChangeCipherSpec + Finished:
- ClientKeyExchange: the client's ECDHE public key
- Both sides now compute the pre-master secret from the ECDHE exchange, then derive the master secret and session keys
- ChangeCipherSpec: "I am switching to encrypted mode now"
- Finished: a hash of all handshake messages so far, encrypted with the new keys (proves no tampering)
Server sends ChangeCipherSpec + Finished:
- ChangeCipherSpec: "I am also switching to encrypted mode"
- Finished: server's hash of all handshake messages, encrypted
After both Finished messages are verified, the handshake is complete. Application data flows encrypted.
TLS 1.2 Full Handshake — 2 Round Trips
Click each step to explore
The Math: Handshake Latency
| Network | Round Trip Time | TLS 1.2 Handshake | Total Before First Byte |
|---|---|---|---|
| Same datacenter | 1 ms | 2 ms | ~3 ms (+ TCP handshake) |
| Same region | 10 ms | 20 ms | ~30 ms |
| Cross-continent | 100 ms | 200 ms | ~300 ms |
| Mobile (4G) | 50-150 ms | 100-300 ms | ~150-450 ms |
On high-latency connections (mobile, cross-continent), the TLS 1.2 handshake adds hundreds of milliseconds to every new connection. This is a real user experience problem. It is the primary reason TLS 1.3 was designed with a 1-round-trip handshake, and why HTTP/2 multiplexes requests over a single connection to avoid repeated handshakes.
Part 3: The TLS 1.3 Handshake — Faster and More Secure
TLS 1.3 (RFC 8446, published 2018) is a significant redesign. It reduces the handshake to 1 round trip and removes insecure options that existed in TLS 1.2.
What Changed
The key insight: instead of negotiating the key exchange algorithm first, then exchanging keys, TLS 1.3 combines both steps. The client guesses which key exchange the server will accept and sends its key share in the very first message.
Client sends ClientHello + key_share:
- Supported cipher suites (TLS 1.3 only has 5 cipher suites, all secure)
- Client's ECDHE public key (sent immediately, not after negotiation)
- Supported key exchange groups
Server sends ServerHello + key_share + EncryptedExtensions + Certificate + CertificateVerify + Finished:
- All in one flight
- Server's ECDHE public key
- Certificate and signature (proving identity)
- Finished (handshake integrity)
Client sends Finished:
- Verifies server's Finished
- Sends its own Finished
Application data can flow immediately after the client sends Finished. 1 round trip total.
TLS 1.2 vs TLS 1.3 Handshake
TLS 1.2
2 round trips to first encrypted data
TLS 1.3
1 round trip to first encrypted data
What TLS 1.3 Removed
TLS 1.3 is not just faster — it is more secure because it removed dangerous features:
- Static RSA key exchange: no forward secrecy. Removed.
- CBC mode ciphers: vulnerable to padding oracle attacks. Removed.
- RC4, DES, 3DES: weak ciphers. Removed.
- Compression: vulnerable to CRIME/BREACH attacks. Removed.
- Renegotiation: complex state machine, attack surface. Removed.
TLS 1.3 has only 5 cipher suites, and all of them are considered secure. There are no configuration choices that lead to weak encryption. Compare this to TLS 1.2, where a misconfigured server could negotiate RC4 or 3DES. TLS 1.3 made it nearly impossible to configure TLS badly.
0-RTT Resumption — Sending Data Before the Handshake Completes
For repeat connections (where the client has connected to this server before), TLS 1.3 supports 0-RTT resumption:
- On the first connection, the server sends the client a session ticket
- On the next connection, the client includes early data encrypted with a key derived from the session ticket
- The server can process this data before the handshake completes
Connection 1: Normal TLS 1.3 handshake (1-RTT)
→ Server sends session ticket after handshake
Connection 2: Client sends ClientHello + early data (0-RTT)
→ Server processes early data IMMEDIATELY
→ Handshake completes in background
0-RTT data is vulnerable to replay attacks. An attacker who records the 0-RTT data can resend it to the server, and the server may process it again. For this reason, 0-RTT should only be used for idempotent requests (GET, HEAD) — never for state-changing operations (POST, PUT, DELETE). Most servers disable 0-RTT by default for safety.
Part 4: Cipher Suites Decoded
A cipher suite is a named combination of algorithms for each part of the TLS connection. Understanding the naming tells you exactly what is happening.
TLS 1.2 Cipher Suite Format
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ Hash: SHA-384 (for HMAC/PRF)
│ │ │ │ │ └───── Mode: GCM (authenticated encryption)
│ │ │ │ └───────── Key size: 256-bit
│ │ │ └───────────── Cipher: AES (symmetric encryption)
│ │ └────────────────────── Authentication: RSA (certificate type)
│ └──────────────────────────── Key Exchange: ECDHE (ephemeral Diffie-Hellman)
└───────────────────────────────── Protocol: TLS
TLS 1.3 Cipher Suite Format (Simplified)
TLS_AES_256_GCM_SHA384
│ │ │ │ │
│ │ │ │ └─ Hash: SHA-384
│ │ │ └───── Mode: GCM
│ │ └───────── Key size: 256-bit
│ └───────────── Cipher: AES
└────────────────── Protocol: TLS
TLS 1.3 cipher suites are shorter because key exchange is always ECDHE and authentication is determined by the certificate type, not the cipher suite.
The 5 TLS 1.3 cipher suites:
| Cipher Suite | Encryption | Hash | Notes |
|---|---|---|---|
| TLS_AES_256_GCM_SHA384 | AES-256-GCM | SHA-384 | Most common, strong |
| TLS_AES_128_GCM_SHA256 | AES-128-GCM | SHA-256 | Slightly faster |
| TLS_CHACHA20_POLY1305_SHA256 | ChaCha20-Poly1305 | SHA-256 | Better on devices without AES-NI |
| TLS_AES_128_CCM_SHA256 | AES-128-CCM | SHA-256 | IoT use cases |
| TLS_AES_128_CCM_8_SHA256 | AES-128-CCM-8 | SHA-256 | IoT, shorter tag |
ChaCha20-Poly1305 is the cipher suite designed for mobile devices and ARM processors that lack hardware AES acceleration. On x86 servers with AES-NI, AES-256-GCM is faster. On mobile phones and ARM-based edge devices, ChaCha20 can be 3x faster. Modern TLS libraries automatically select the optimal cipher based on hardware.
Part 5: What "SSL Handshake Failed" Actually Means
When you see "SSL handshake failed" in your logs, it means the client and server could not complete the handshake. Here are the four main causes:
1. Protocol Version Mismatch
The client and server do not support any common TLS version.
# Server only supports TLS 1.0/1.1 (ancient, insecure)
# Client requires TLS 1.2+ (modern browsers, Go 1.18+, Python 3.10+)
# Result: handshake failure
# Test what versions a server supports
openssl s_client -connect example.com:443 -tls1_2
# If this succeeds but -tls1_3 fails, the server does not support TLS 1.3
2. Cipher Suite Mismatch
The client and server have no cipher suites in common.
# Force a specific cipher to test
openssl s_client -connect example.com:443 -cipher ECDHE-RSA-AES256-GCM-SHA384
# If the server only accepts ciphers the client does not support: handshake failure
3. Certificate Rejected
The client does not trust the server's certificate (expired, wrong domain, untrusted CA, incomplete chain).
# Test and see certificate details
openssl s_client -connect example.com:443 -servername example.com 2>&1 | head -30
# Look for: "Verify return code: 0 (ok)" or an error code
4. Client Certificate Required (mTLS)
The server requires a client certificate (mutual TLS) but the client did not provide one.
# Connect with a client certificate for mTLS
openssl s_client -connect example.com:443 \
-cert client.crt \
-key client.key \
-CAfile ca.crt
At 3 AM, a payment gateway integration broke with "SSL handshake failed." The vendor had rotated their TLS certificate and the new one used an intermediate CA that was not in our custom CA bundle. Our application pinned to the old CA chain. The fix was updating the CA bundle, but finding the root cause took two hours because the error message just said "handshake failed" with no detail. Lesson learned: always use openssl s_client as your first debugging step — it shows you exactly where in the handshake things broke.
Part 6: Debugging with openssl s_client
The openssl s_client command is the single most important TLS debugging tool. It simulates a TLS client handshake and shows you everything that happened.
# Basic connection test
openssl s_client -connect devopsbeast.com:443 -servername devopsbeast.com
# Key lines in the output:
# ---
# Certificate chain
# 0 s:CN = devopsbeast.com ← leaf cert (your server)
# i:C = US, O = Let's Encrypt, CN = R3 ← signed by Let's Encrypt R3
# 1 s:C = US, O = Let's Encrypt, CN = R3 ← intermediate cert
# i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
# ---
# Server certificate (PEM encoded)
# ---
# SSL handshake has read 3471 bytes and written 399 bytes
# ---
# New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
# Server public key is 256 bit
# Server Temp Key: X25519, 253 bits
# ---
# Verify return code: 0 (ok) ← 0 means chain verified successfully
The "Verify return code" at the bottom of openssl s_client output is the single most important line. Code 0 means the certificate chain verified successfully. Any other code tells you exactly what is wrong: 10 = expired, 18 = self-signed, 19 = self-signed in chain, 20 = unable to get local issuer, 21 = unable to verify first certificate. Memorize codes 0, 10, 20, and 21 — they cover 90% of certificate errors.
# Show the full certificate chain
openssl s_client -showcerts -connect devopsbeast.com:443 -servername devopsbeast.com
# Test with a specific TLS version
openssl s_client -connect devopsbeast.com:443 -tls1_3
# Test with a specific cipher
openssl s_client -connect devopsbeast.com:443 -ciphersuites TLS_AES_256_GCM_SHA384
# Extract just the server certificate to a file
openssl s_client -connect devopsbeast.com:443 -servername devopsbeast.com 2>/dev/null \
| openssl x509 -out server.crt
# Check certificate expiry from a remote server
echo | openssl s_client -connect devopsbeast.com:443 -servername devopsbeast.com 2>/dev/null \
| openssl x509 -noout -dates
# notBefore=Mar 1 00:00:00 2026 GMT
# notAfter=May 30 23:59:59 2026 GMT
Always use -servername with openssl s_client. Without it, no SNI extension is sent, and the server may return the wrong certificate (or a default certificate). This is one of the most common reasons people say "OpenSSL shows a different cert than my browser" — they forgot -servername.
Key Concepts Summary
- The TLS handshake establishes cipher suite, authenticates the server, and exchanges keys — all before any HTTP data flows
- TLS 1.2 requires 2 round trips — ClientHello/ServerHello, then key exchange/finish
- TLS 1.3 requires 1 round trip — the client sends its key share in the first message, cutting latency in half
- TLS 1.3 removed insecure features — static RSA, CBC ciphers, RC4, 3DES, compression, renegotiation
- 0-RTT resumption allows data in the first packet for repeat connections, but is vulnerable to replay attacks
- Cipher suites name every algorithm used: key exchange, authentication, encryption, and hash
- Handshake failures happen for four reasons: version mismatch, cipher mismatch, certificate rejected, or client cert missing
- openssl s_client is the essential debugging tool — it shows the full handshake, certificate chain, and verification result
- Verify return code 0 means the chain is valid; any other code tells you exactly what is wrong
Common Mistakes
- Not using
-servernamewithopenssl s_client, causing it to return the wrong certificate on servers with multiple domains - Confusing TLS version in the protocol with TLS version in the cipher suite — the negotiated version is what matters
- Assuming "SSL handshake failed" is a certificate problem — it could be a version or cipher mismatch instead
- Enabling 0-RTT for all requests including POST/PUT — 0-RTT is only safe for idempotent operations
- Not testing TLS configuration changes before deploying — use
openssl s_clientto verify the new config accepts connections - Ignoring handshake latency on high-RTT connections — 200ms per new connection adds up fast on mobile networks
What is Next
You now understand what happens during the TLS handshake and how to debug handshake failures. But the handshake depends on one critical piece: the server's certificate. How does the client decide whether to trust it?
In the next lesson, we will open up an X.509 certificate and examine every field. You will learn how certificate chains work, why the "missing intermediate" error is the most common TLS problem, and how Let's Encrypt changed the certificate landscape forever.
What is the primary advantage of TLS 1.3 over TLS 1.2?