The TLS 1.3 Handshake
The TLS 1.3 handshake (RFC 8446 §2, August 2018) is the negotiation that runs once at the start of a TLS connection to agree on a protocol version and cipher, perform an ephemeral Elliptic-Curve Diffie-Hellman (ECDHE) key exchange, authenticate the server (and optionally the client) with a certificate, and derive the symmetric keys the record layer then uses. Its defining achievement is latency: the full handshake completes in one round trip (1-RTT) — the client can send encrypted application data immediately after receiving the server’s first flight — versus two round trips in TLS 1.2. It pulls this off with a single structural bet: the client guesses the key-exchange group and sends its Diffie-Hellman public value (
key_share) in the very first message, so the server can compute the shared secret and start encrypting immediately. This note walks the message flow, the ECDHE exchange, the HKDF key schedule, the wrong-guess recovery (HelloRetryRequest), and the exact round-trip saving versus TLS 1.2.
Mental Model — Speak First, Confirm Later
TLS 1.2’s handshake was a polite conversation: the client asks what groups do you support?, the server answers, and only then do they exchange Diffie-Hellman values — two full round trips of back-and-forth before any data flows. TLS 1.3 flips this to optimistic, speculative negotiation. The client doesn’t ask; it assumes. In its first message it says, in effect, “I’m going to use X25519 for key exchange, and here is my public key already.” If the server is happy with that guess — and for the overwhelmingly common groups it always is — the server can immediately do its half of the Diffie-Hellman, derive keys, and send back its certificate already encrypted. One round trip, done.
sequenceDiagram participant C as Client participant S as Server Note over C: generate ephemeral ECDHE keypair C->>S: ClientHello<br/>+ key_share (client DH public)<br/>+ supported_groups, supported_versions<br/>+ signature_algorithms, cipher_suites Note over S: pick group + cipher,<br/>do ECDHE, derive handshake keys S->>C: ServerHello + key_share (server DH public) Note over S,C: --- everything below is ENCRYPTED --- S->>C: {EncryptedExtensions} S->>C: {CertificateRequest*} (only if mutual TLS) S->>C: {Certificate} (server's cert chain) S->>C: {CertificateVerify} (signature over transcript) S->>C: {Finished} (MAC = key confirmation) Note over C: verify cert + signature + Finished,<br/>derive application keys C->>S: {Certificate*}{CertificateVerify*} (only if mutual TLS) C->>S: {Finished} C->>S: [Application Data] S->>C: [Application Data]
The 1-RTT full handshake. What it shows: the client’s single first message already carries its Diffie-Hellman public value, so after the server’s single reply flight both sides share a secret and the server’s certificate arrives already encrypted ({...} = encrypted under handshake keys; [...] = encrypted under application keys; * = present only in mutual TLS). The insight to take: application data flows after exactly one round trip because the expensive negotiation and the key exchange were fused into the first exchange. Compare TLS 1.2, where a separate “what groups do you support?” round trip had to happen first — that is the round trip TLS 1.3 deleted.
The Three Phases of the Handshake
RFC 8446 §2 organises the handshake into three logical phases, even though they overlap on the wire:
- Key Exchange. “Establish shared keying material and select the cryptographic parameters. Everything after this phase is encrypted.” This is
ClientHello↔ServerHelloplus thekey_sharevalues. - Server Parameters. “Establish other handshake parameters (whether the client is authenticated, application-layer protocol support, etc.).” This is
EncryptedExtensionsand, if used,CertificateRequest. - Authentication. “Authenticate the server (and, optionally, the client) and provide key confirmation and handshake integrity.” This is
Certificate,CertificateVerify, andFinishedon each authenticating side.
The genius of the layout is that phase 1 finishes after just the first exchange, so phases 2 and 3 — the certificate, the identity — happen inside encryption. In TLS 1.2 the certificate was in the clear; here the server’s identity is hidden from any passive observer.
Message-by-Message Walk-Through
ClientHello — the speculative opener
The client sends a single ClientHello. On the wire it looks like TLS 1.2 (its legacy_version field is 0x0303, and it even sends a dummy legacy_session_id for middlebox compatibility), but the real negotiation is in the extensions:
supported_versions— lists0x0304(TLS 1.3). This, notlegacy_version, is where 1.3 is actually offered (Transport Layer Security Fundamentals covers why the version is disguised).supported_groups— the named Diffie-Hellman groups the client can do, in preference order: elliptic-curve groups like X25519 and secp256r1, and the finite-field groupsffdhe2048+.key_share— the client’s actual ephemeral public keys, one per group it is willing to bet on (usually just X25519, sometimes X25519 + secp256r1 to hedge). This is the optimistic guess: the client has already generated an ephemeral keypair and is sending the public half before the server has said which group it wants.signature_algorithms— which signature schemes the client will accept for the server’s certificate (RSASSA-PSS, ECDSA, Ed25519, …).cipher_suites— the AEAD+hash suites the client supports, e.g.TLS_AES_128_GCM_SHA256.- optional
pre_shared_keyandpsk_key_exchange_modesfor resumption (TLS Session Resumption and 0-RTT), andserver_name(SNI).
ServerHello — the acceptance
The server picks a version, a cipher suite, and a group; does its own half of the ECDHE using the client’s key_share; and replies with a ServerHello containing its key_share (the server’s ephemeral public value). That is all the server needs to send in the clear — the moment the client reads the server’s key_share, both sides can compute the identical shared secret, and everything after ServerHello is encrypted. (The ServerHello.random also carries the downgrade sentinel described in Transport Layer Security Fundamentals.)
The encrypted server flight
Under the freshly-derived handshake-traffic keys, the server sends, back-to-back:
{EncryptedExtensions}— extensions that aren’t needed to establish keys but shouldn’t leak (ALPN protocol selection, etc.). Sending them encrypted is a privacy win over 1.2.{CertificateRequest*}— present only if the server wants the client to authenticate too (mutual TLS). It can carry acertificate_authoritieslist telling the client which CAs are acceptable. The message-flow change this introduces is owned by Mutual TLS — this note keeps to the default server-only case.{Certificate}— the server’s X.509 certificate chain, validated by the client up to a trusted root (Public Key Infrastructure and Certificate Chains, X.509 Certificate Validation).{CertificateVerify}— a signature proving the server holds the private key matching the certificate it just sent (detailed below).{Finished}— a MAC over the whole transcript so far, proving the server derived the same keys and that nobody tampered with the handshake (below).
The client’s reply and first data
The client validates the certificate chain, checks the CertificateVerify signature, and verifies the server’s Finished MAC. If a CertificateRequest arrived, it now sends its own {Certificate} and {CertificateVerify} (the mTLS path). Then it sends its own {Finished} — and, critically, it can send [Application Data] in the same flight, encrypted under the newly-derived application-traffic keys. The client’s very first data reaches the server after exactly one round trip.
The Key Exchange — Ephemeral ECDHE and Forward Secrecy
The shared secret is established by Diffie-Hellman, and in TLS 1.3 it is always ephemeral (the “E” in ECDHE / DHE) — there is no static-key option. Walk the mechanism concretely for the common X25519 case (from the byte-level trace at The Illustrated TLS 1.3 Connection):
- The client generates a random 32-byte private scalar
aand computes its public valueA = a·G(scalar-multiply the curve’s base point), sendingAin itskey_share. - The server generates its own random private scalar
b, computesB = b·G, sendsBin itskey_share. - Each side computes the shared point: the client computes
a·B, the server computesb·A. By the commutativity of scalar multiplication,a·B = a·(b·G) = b·(a·G) = b·A— both arrive at the identical 32-byte shared secret without it ever crossing the wire.
The word ephemeral is the whole point. a and b are freshly generated for this connection and discarded the instant the handshake finishes. This is what delivers forward secrecy: the long-term certificate key only ever signs (in CertificateVerify) — it never encrypts or transports the session secret. So an attacker who records the ciphertext today and steals the server’s certificate private key years later still cannot decrypt: the ephemeral a/b that actually produced the keys are long gone. TLS 1.2’s static-RSA mode, where the client encrypted the session secret directly to the server’s long-term public key, had exactly this catastrophic weakness — steal the key once, decrypt every past session you recorded — which is why TLS 1.3 deleted it.
The Key Schedule — From Shared Secret to Traffic Keys via HKDF
A raw Diffie-Hellman secret is not used directly as an encryption key. TLS 1.3 runs it through a key schedule built entirely on HKDF (HMAC-based Extract-and-Expand Key Derivation Function), redesigned from TLS 1.2’s ad-hoc PRF specifically so cryptographers can prove its “key separation” — that keys used for one purpose can’t be confused with or derived from keys for another. Two helper functions do all the work (RFC 8446 §7.1):
HKDF-Expand-Label(Secret, Label, Context, Length)— an HKDF-Expand whose info parameter is a structured label. The label string on the wire is always"tls13 "prepended to the givenLabel(so"finished"becomes"tls13 finished"), which domain-separates TLS 1.3 keys from any other use of HKDF.Derive-Secret(Secret, Label, Messages)=HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length)— the same, but the context is the hash of the handshake transcript so far, which binds every derived secret to the exact sequence of handshake messages seen.
The schedule proceeds through three master secrets, each an HKDF-Extract:
0
|
v
PSK -> HKDF-Extract = Early Secret (salt=0, IKM=PSK or all-zeros if none)
|
+--> Derive-Secret(., "ext binder"/"res binder", "")
+--> Derive-Secret(., "c e traffic", ClientHello) -> client_early_traffic_secret (0-RTT)
+--> Derive-Secret(., "e exp master", ClientHello)
|
v
Derive-Secret(Early Secret, "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret (salt=derived, IKM=(EC)DHE shared secret)
|
+--> Derive-Secret(., "c hs traffic", CH..SH) -> client_handshake_traffic_secret
+--> Derive-Secret(., "s hs traffic", CH..SH) -> server_handshake_traffic_secret
|
v
Derive-Secret(Handshake Secret, "derived", "")
|
v
0 -> HKDF-Extract = Master Secret (salt=derived, IKM=all-zeros)
|
+--> Derive-Secret(., "c ap traffic", CH..server Finished) -> client_application_traffic_secret_0
+--> Derive-Secret(., "s ap traffic", CH..server Finished) -> server_application_traffic_secret_0
+--> Derive-Secret(., "exp master", CH..server Finished)
+--> Derive-Secret(., "res master", CH..client Finished) -> resumption_master_secret
Reading the schedule top to bottom: the Early Secret is extracted from the pre-shared key (or a block of zeros if there is no PSK); it is what a 0-RTT resumption uses to encrypt early data. The Handshake Secret mixes in the ECDHE shared secret and yields the client_handshake_traffic_secret / server_handshake_traffic_secret — the keys that encrypt everything from EncryptedExtensions onward. The Master Secret (extracted with a zero input-keying-material, since all the entropy is already folded in) yields the client_application_traffic_secret_0 / server_application_traffic_secret_0 for application data, plus the resumption_master_secret that seeds a future session’s PSK (TLS Session Resumption and 0-RTT). Every one of those traffic secrets is finally turned into an actual AEAD key and IV by two more expansions (RFC 8446 §7.3): write_key = HKDF-Expand-Label(secret, "key", "", key_length) and write_iv = HKDF-Expand-Label(secret, "iv", "", iv_length). The labels — "derived", "c hs traffic", "s hs traffic", "c ap traffic", "s ap traffic", "exp master", "res master", "key", "iv" — are quoted exactly from §7.1; getting a single label byte wrong makes both sides derive different keys and the handshake fails closed.
The reason there are separate handshake and application secrets is subtle but important: it means the keys protecting the certificate exchange are cryptographically independent from the keys protecting application data. Even a flaw that somehow exposed a handshake key would not directly hand over the application key, because you would still need the Master Secret’s derivation to get there.
CertificateVerify and Finished — Authentication and Key Confirmation
Two messages do the actual “prove who you are and that nothing was tampered with” work (RFC 8446 §4.4):
CertificateVerify is a digital signature over the transcript hash, computed with the private key that matches the just-sent certificate. To prevent an attacker from replaying a signature captured in a different context, the signed content is a fixed prefix (64 spaces) followed by a context string — "TLS 1.3, server CertificateVerify" for the server, "TLS 1.3, client CertificateVerify" for the client — and then the transcript hash. The signature scheme is RSASSA-PSS, ECDSA, or EdDSA per the negotiated signature_algorithms. This is the step that actually authenticates the peer: the certificate merely claims an identity and a public key; the CertificateVerify signature proves the sender holds the matching private key and is signing this specific handshake (bound by the transcript hash, which includes both sides’ random values, so it can’t be replayed against a different session).
Finished is a MAC over the entire handshake transcript, and it does two jobs: handshake integrity (if a man-in-the-middle altered any earlier message — stripped an extension, forced a downgrade — the transcript hashes won’t match and the MAC verification fails) and key confirmation (a correct Finished proves the sender derived the same keys, so both sides know the key exchange actually succeeded). It is computed as HMAC(finished_key, Transcript-Hash(all messages so far)), where finished_key = HKDF-Expand-Label(BaseKey, "finished", "", Hash.length) and BaseKey is the sender’s handshake-traffic secret. Because the Finished MAC covers the transcript, and the transcript includes the CertificateVerify and every ClientHello extension, the two messages together make the whole handshake tamper-evident end to end.
When the Client Guesses Wrong — HelloRetryRequest
The 1-RTT optimism depends on the client’s key_share guess matching a group the server accepts. Sometimes it doesn’t — the client offered only X25519 but the server requires secp384r1, or the client sent no key_share at all. Rather than fail, the server sends a HelloRetryRequest (RFC 8446 §2.1 / §4.1.4): “when the server has not provided a sufficient key_share extension,” it tells the client which group to use instead. The client generates a fresh keypair in that group and resends its ClientHello with the corrected key_share.
The cost is exactly one extra round trip — a HelloRetryRequest handshake is 2-RTT, no better than TLS 1.2. That is the price of the speculative design: it is optimal when the guess is right (the common case) and merely as-good-as-1.2 when the guess is wrong. To keep the handshake tamper-evident across the retry, the transcript is not reset: the original ClientHello is folded into the transcript hash as a synthetic “message_hash” so a downgrade attacker cannot rewrite the retry unnoticed. In practice, clients tune their default key_share (almost always X25519) to the groups servers actually prefer, so HelloRetryRequest is rare on the modern web.
How TLS 1.3 Cut a Round Trip — the RTT Accounting
The concrete saving versus TLS 1.2 is worth counting message-for-message. In TLS 1.2 (RFC 5246 §7.3), the full handshake is:
Client Server
ClientHello ------->
<------- ServerHello, Certificate, ServerKeyExchange,
[CertificateRequest], ServerHelloDone (RTT 1)
ClientKeyExchange,
[Certificate, CertificateVerify],
ChangeCipherSpec, Finished --->
<------- ChangeCipherSpec, Finished (RTT 2)
Application Data ------->
TLS 1.2 needs two round trips before the client sends application data. The reason is that the key exchange couldn’t start until the server had sent its ServerKeyExchange (with the DH parameters) and the client had replied with its ClientKeyExchange — a full extra exchange, plus the separate ChangeCipherSpec signal to switch on encryption.
TLS 1.3 collapses this. By putting the client’s key_share in the first message, the server can finish the key exchange and authenticate itself in its first reply, and the client sends data in its response to that — one round trip. The ChangeCipherSpec message is gone entirely (it survives only as a dummy “middlebox compatibility” record, ignored by 1.3). And a resumed connection can go further still: with a pre-shared key from a previous session, TLS 1.3 offers 0-RTT, letting the client send application data in its very first flight before any reply — at the cost of a replay-safety caveat, all of which belongs to TLS Session Resumption and 0-RTT.
| full handshake | resumed | |
|---|---|---|
| TLS 1.2 | 2-RTT | 1-RTT (abbreviated) |
| TLS 1.3 | 1-RTT | 1-RTT, or 0-RTT (with replay caveat) |
Round trips to first application byte. The insight: TLS 1.3’s whole latency story is “fuse the key exchange into the hello messages,” which removes one mandatory round trip from every fresh connection and enables the 0-RTT resumption case.
A Concrete Trace
The byte-level walk-through at tls13.xargs.org makes the abstract flow tangible. A typical connection: the client generates an X25519 ephemeral keypair; sends a ClientHello offering TLS_AES_256_GCM_SHA384 (among others) with its X25519 public key in key_share; the server replies ServerHello selecting that suite and its own X25519 public key. Both compute the 32-byte shared secret via X25519, then run the key schedule with SHA-384 (the hash named by the chosen suite): empty/zero → Early Secret → (mix in shared secret) → Handshake Secret → client/server handshake traffic secrets → AES-256-GCM keys and IVs. The server’s EncryptedExtensions, Certificate, CertificateVerify, and Finished all arrive encrypted under those handshake keys; both sides then derive application traffic secrets and exchange [Application Data] under fresh AES-256-GCM keys. Every symmetric key in that trace traces back, through HKDF, to the one ephemeral Diffie-Hellman secret — and to nothing that the long-term certificate key could later reveal.
Failure Modes and Common Misunderstandings
“The certificate is what authenticates the server.” Half true. The certificate asserts an identity and a public key; it is the CertificateVerify signature over the live transcript that proves the server holds the matching private key for this handshake. A stolen certificate (public data — it’s sent in the clear… well, encrypted in 1.3, but not secret) is useless without the private key needed to produce that signature.
“A HelloRetryRequest means something is broken.” No — it means the client’s key_share guess didn’t match the server’s preference, and the protocol is doing exactly what it should: falling back to a 2-RTT exchange in the right group. It’s a performance footnote, not an error.
“Encrypted handshake means the whole thing is hidden.” ClientHello and ServerHello are still in the clear (they must be — they carry the key_share needed to derive the encryption keys). What’s encrypted is everything after ServerHello, most importantly the certificate. And the SNI in ClientHello leaks the hostname unless Encrypted Client Hello is in use.
Key confirmation vs authentication are different. CertificateVerify authenticates identity; Finished confirms both sides derived the same keys and the transcript wasn’t tampered with. A handshake needs both — identity without key confirmation would let an attacker splice a valid certificate onto a manipulated key exchange.
Getting an HKDF label wrong fails silently-then-loudly. Because both sides derive keys independently, a one-byte mismatch in any label or transcript produces different keys and the first AEAD-protected message fails to decrypt/verify — the handshake aborts rather than proceeding insecurely. TLS 1.3 fails closed, which is the correct direction.
See Also
- Transport Layer Security Fundamentals — the goals, the record protocol, cipher suites, downgrade protection, and SNI/ECH that frame this handshake
- TLS Session Resumption and 0-RTT — the PSK path, the abbreviated handshake, and 0-RTT early data (the replay caveat lives there)
- Mutual TLS and mTLS in Service Mesh — the client-certificate path (
CertificateRequest+ clientCertificate/CertificateVerify); this note keeps to the server-only case - Public Key Infrastructure and Certificate Chains and X.509 Certificate Validation — how the client actually validates the server’s
Certificate - The TCP Three-Way and Four-Way Handshake — the transport handshake that must complete before the TLS handshake even starts (a hidden extra round trip over TCP)
- QUIC Transport Protocol — folds this TLS 1.3 handshake into the transport handshake to save even that TCP round trip
- UP: Networking and Protocols MOC (§5 Transport Security)