TLS handshake steps
Two articles have now built the pieces separately: Encryption: SSL/TLS covered how ECDHE derives a shared key without transmitting it, and Introduction to HTTPS and PKI covered how a certificate chain proves identity. Neither showed the actual sequence of messages that does both at once, in one connection, before a single byte of HTTP ever moves. That sequence — the TLS handshake — is what this article walks through, message by message, using TLS 1.3, the version RFC 8446 defines and the one every current browser and server should be negotiating by default.
Capturing a real handshake
openssl s_client with -msg prints every handshake message as it's sent and received, which turns the abstract sequence into something concrete to read against:
>>> TLS 1.3, Handshake [length 00f0], ClientHello
<<< TLS 1.3, Handshake [length 007a], ServerHello
<<< TLS 1.3, Handshake [length 000a], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0e67], Certificate
<<< TLS 1.3, Handshake [length 0050], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Handshake [length 0034], Finished
That's the real trace, trimmed to the handshake messages themselves — a full run also interleaves ChangeCipherSpec records and raw RecordHeader lines that TLS 1.3 keeps only for compatibility with middleboxes expecting TLS 1.2's wire format; they carry no cryptographic meaning of their own and are safe to ignore when reading the handshake logic. >>> is what the client sent, <<< is what the server sent back. Seven messages, and — notice this now, because it's the detail that makes TLS 1.3 meaningfully faster than its predecessor — only two round trips: the client's ClientHello out, everything from ServerHello through the server's Finished back in one flight, and the client's own Finished closing it out.
ClientHello: proposing everything up front
The client's very first message does more work than its name suggests. It's not a bare "hello" — it's a complete proposal of everything the client is willing to use for this connection, sent before any negotiation has actually happened:
- A random nonce, 32 bytes generated fresh for this connection, which feeds into the key derivation later and guarantees that even two connections using identical parameters end up with different final keys.
- The TLS versions the client supports.
- A list of cipher suites — the same kind of entries
openssl ciphers -s -tls1_3printed in Encryption: SSL/TLS — ranked in the client's preferred order. - A key_share extension containing the client's own ephemeral ECDHE public value, computed and sent speculatively, before the server has even confirmed which key exchange group it wants to use.
That last point is TLS 1.3's actual speed trick, and it's worth being explicit about why it matters. TLS 1.2 negotiated the key exchange group first, in one round trip, and only started the actual Diffie-Hellman exchange in a second round trip after that negotiation completed. TLS 1.3 has the client simply guess — send an ECDHE public value for the group it thinks the server will accept, based on common, well-supported groups — in the very first message. If the server is willing to use that same group, which it almost always is given how few groups are in wide practical use, the key exchange is already half-done by the time the server's first response arrives, collapsing what took two round trips down to one.
ServerHello and the encrypted flight that follows it
The server answers with its own random nonce, the cipher suite it selected from the client's offered list, and its own ECDHE public value for the same group the client guessed. At this exact point — and this is worth pausing on, because it's a real architectural change from TLS 1.2 — both sides now have everything they need to compute the same shared secret the way Encryption: SSL/TLS described, and everything the server sends from here onward in this handshake is already encrypted with a key derived from that secret.
That's why EncryptedExtensions, Certificate, and CertificateVerify all arrive in the same encrypted flight, back to back, rather than as separate plaintext round trips the way TLS 1.2 sent them:
- EncryptedExtensions carries any additional negotiated parameters that didn't need to be public in
ServerHelloitself. - Certificate is the server's certificate chain — the leaf and intermediate
openssl s_client's-showcertsoutput already showed in the PKI article. - CertificateVerify is the step that actually ties the certificate to this specific connection: the server signs a hash of the entire handshake transcript so far, using the private key that matches the public key in its certificate. This is the proof that whoever is on the other end of the connection genuinely holds that certificate's private key — not just possesses a copy of the certificate itself, which is public information anyone could have collected. Without
CertificateVerify, an attacker could replay a legitimate server's certificate without ever having the corresponding private key, and the client would have no way to tell the difference.
Finished: proving both sides agree on everything
Both Finished messages — the server's, and the client's own reply — serve the same purpose: each is a MAC (message authentication code) computed over the entire handshake transcript exchanged so far, keyed with a value derived from the handshake's own secret. If anything in the handshake was tampered with in transit — a downgraded cipher suite, a stripped extension, a substituted key share — the transcript each side computed would differ, and the Finished MAC would fail to verify. This is what makes the handshake itself tamper-evident, on top of everything TLS does to protect the application data that rides on top of it afterward.
Once the client's Finished message is sent and verified, the handshake is complete, and both sides derive fresh symmetric traffic keys — related to, but distinct from, the handshake keys used for EncryptedExtensions onward — for the actual application data.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: ClientHello (nonce, ciphers, key_share)
S->>C: ServerHello (nonce, chosen cipher, key_share)
Note over C,S: Shared secret now derivable by both sides
S->>C: EncryptedExtensions
S->>C: Certificate
S->>C: CertificateVerify (signs handshake transcript)
S->>C: Finished (MAC over transcript)
C->>S: Finished (MAC over transcript)
Note over C,S: Application data now flows, encrypted
What this buys over TLS 1.2, concretely
TLS 1.2's handshake needed two round trips before any application data could move: one to negotiate the cipher suite and key exchange group, a second to actually perform the key exchange and verify the certificate. TLS 1.3's speculative key_share collapses that into one round trip in the common case — a real, measurable latency win on every single new connection, which matters enormously at the scale of a busy web service opening thousands of fresh TLS connections per second. RFC 8446 also closed off the specific failure mode Encryption: SSL/TLS already covered: because static RSA key exchange no longer exists as a negotiable option, every TLS 1.3 handshake is forward-secret by construction, not by an administrator's optional configuration choice.
Practical scenario: a handshake that completes but an application that still fails
A backend service upgrades its TLS library and, immediately after, a specific set of older client devices — a fleet of point-of-sale terminals running an embedded OS that hasn't been updated in years — start failing to connect entirely, while every modern browser and server continues working without any issue.
Reproducing the same restriction in a lab — a test server configured, like the upgraded production service, to accept only TLS 1.3 — and connecting with a client deliberately forced down to TLS 1.2 shows exactly what the terminals are hitting:
40E76525C5740000:error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:../ssl/record/rec_layer_s3.c:1599:SSL alert number 70
SSL alert number 70 is protocol_version — the server received a ClientHello proposing only TLS 1.2 and rejected it outright, because TLS 1.2 isn't in its accepted list at all anymore. That's a version mismatch specifically, not a network-level timeout: the TCP connection succeeded, and the server responded — it just responded by refusing the handshake, which is the correct, by-design behavior for a server that no longer wants to negotiate that version. The upgrade tightened the server's minimum supported TLS version — a reasonable, security-positive change on its own — but the embedded terminals' TLS stack is old enough that it never learned to speak anything newer, and now has nothing in common with what the server will accept.
This is a genuinely hard trade-off with no purely technical fix: lowering the server's minimum supported version to restore compatibility with the old terminals also reintroduces whatever weakness the upgrade was removing in the first place. The actual resolution is almost always operational rather than cryptographic — schedule the terminal fleet for a firmware update or hardware replacement, and treat the version mismatch as a forcing function surfacing a device inventory problem, rather than reverting the server's security posture to accommodate hardware that should have been retired already.
Never loosen a production TLS configuration without confirming exactly which clients need it and why
Widening accepted cipher suites or TLS versions to fix one client's compatibility problem affects every client connecting to that server, not just the one you're troubleshooting. Before changing a production TLS configuration, confirm the change in a lab against the specific client version reported broken, and prefer a narrowly scoped fix — a separate listener or load balancer rule for the legacy client population — over loosening the default configuration everyone else also depends on.
Practice exercises
- Run the
openssl s_client -msgcommand above against a site you use regularly and count the round trips: how many messages does the client send before it can send its ownFinished? - Explain, in your own words, why
CertificateVerifyis necessary even though theCertificatemessage already proves the server holds a certificate for the right domain, signed by a trusted CA. - A teammate says TLS 1.3 is "just TLS 1.2 but faster." Using the ClientHello's speculative
key_shareas your example, explain what's structurally different about the handshake itself, not just its speed.
That completes the path from a bare TCP connection to a fully authenticated, encrypted channel: the math that derives a shared key, the chain of trust that proves who holds it, and the exact messages that tie both together. Everything covered under HTTPS and PKI so far protects one specific conversation between two parties who already found each other. It also says nothing about where that certificate came from, who renews it, or what happens on the morning it expires — which is the half of TLS that actually causes outages, and the subject of the next article.