TLS handshake
A new TLS 1.3 connection opens with the same short conversation every time: two hellos, a Diffie-Hellman agreement, a key schedule, a certificate, a signature and two MACs. We'll step through it in six exhibits using the browser's own crypto, and each exhibit can replay the handshake RFC 8448 publishes, byte for byte.
Everything runs in this tab through Web Crypto, and the page's own X25519 and HKDF are checked against it as they run. The fresh handshake uses throw-away keys, fictional names and a simplified certificate format. Nothing is sent anywhere, and only your progress is saved.
Preparing the handshakes…
Lab exploredThat's the whole handshake. If you want to go further into AES-GCM and nonces, the Vault lab is next door.
Two hellos, field by field
A TLS 1.3 connection opens with one client message that offers everything at once: cipher suites, key-exchange groups, signature algorithms, the server name, application protocols and a fresh key share. Build one and read it like a packet dissector. Every byte belongs to a field, and every variable-length field starts with its length.
The server answers with a ServerHello naming its cipher suite and carrying its own key share. Its other choices follow, encrypted. If the client's key share is for a group the server doesn't choose, the server asks for another with a HelloRetryRequest.
Point at a byte or a field to light it up. Click it, or press Enter, to pin it.
No field pinned.
This server enables TLS_AES_128_GCM_SHA256 and TLS_AES_256_GCM_SHA384 (Web Crypto has no ChaCha20-Poly1305). It also enables groups x25519 and secp256r1, signs with an ECDSA P-256 certificate and speaks h2 and http/1.1.
The maths
opaque x<0..28−1>: a 1-byte length, <0..216−1>: 2 bytes, <0..224−1>: 3 bytes
|ClientHello| = 4 + 2 + 32 + (1 + |session id|) + (2 + 2s) + (1 + 1) + 2 + ∑extensions (4 + |data|)
suite = first s in the client's list with s ∈ server suites, group = first g in the server's list with g ∈ supported_groups
Why a length first. TLS writes every variable-length vector as its length followed by its bytes, so a parser knows where a field ends without looking inside it. That's how a server skips an extension it doesn't understand: 2 bytes of type, 2 bytes of length, then jump. Why an ordered intersection. Each side lists what it accepts in preference order, and the server takes the first entry both have. The key share is a guess at that answer, and a wrong guess costs one HelloRetryRequest.
In practiceBrowsers offer more: extra cipher suites, GREASE values (RFC 8701) and a hybrid post-quantum key share (X25519MLKEM768) that adds about 1,200 bytes.
DefencePrefer x25519 so most clients never need a retry, and enable only AEAD cipher suites. Test that your server and its middleboxes skip unknown extensions instead of failing.
Two public keys, one secret
Each side makes an X25519 key pair: a 32-byte private key it never sends, and the 32-byte public key that goes into its hello as the key share. From its own private key and the other side's public key, each computes the same 32-byte shared secret, which an observer who saw both public keys can't compute. Both sides here run Web Crypto's X25519, and the page's own implementation of RFC 7748 checks the result.
Or load the handshake RFC 8448 publishes, private keys included, and derive the shared secret the RFC prints.
The client computes with whatever share arrives, and the server with its own.
The maths
K = X25519(a, X25519(b, 9)) = X25519(b, X25519(a, 9)), |a| = |A| = |K| = 32 bytes
X25519(k, u) = u([k] U) on v2 = u3 + 486662u2 + u over GF(2255 − 19), k[0] &= 248, k[31] &= 127, k[31] |= 64
Why both sides agree. The public key A is the base point (u = 9) added to itself a times. Scalar multiplication commutes, [a]([b]G) = [ab]G = [b]([a]G), so each side needs only its own private key and the other's public key. Why an observer can't. Getting a back from A is the elliptic-curve discrete logarithm, about 2126 operations for Curve25519. Why the clamping. Clearing the low 3 bits makes the scalar a multiple of the cofactor 8. Setting bit 254 gives every key the same ladder length, so timing says nothing about the key.
In practiceTLS 1.3 uses a fresh (EC)DHE key pair per connection, so a server key stolen later can't decrypt recorded traffic. That's forward secrecy, which TLS 1.2's RSA key exchange never had.
Watch outRefuse an all-zero shared secret (RFC 8446 section 7.4.2), never reuse an ephemeral key, and take private keys from the platform's cryptographic random generator.
From one secret to every key
The shared secret is never used as a key. TLS 1.3 feeds it through HKDF in a fixed schedule, binding each new secret to a label and to the hash of the handshake so far. Step through RFC 8446 section 7.1 one box at a time. With the RFC 8448 trace loaded, every box has to land on the RFC's value.
| Secret | Transcript | As sent | With the byte flipped |
|---|
Pick a message to flip one bit of one byte.
The maths
HKDF-Extract(salt, IKM) = HMAC-SHA-256(salt, IKM)
HKDF-Expand(PRK, info, L) = first L bytes of T(1) ‖ T(2) ‖ …, T(i) = HMAC(PRK, T(i−1) ‖ info ‖ i)
HkdfLabel = uint16 L ‖ opaque label<7..255> = "tls13 " + label ‖ opaque context<0..255>
Derive-Secret(S, label, M) = HKDF-Expand-Label(S, label, Transcript-Hash(M), 32)
Why extract, then expand. A Diffie-Hellman output is a point coordinate, so it isn't a uniformly random string. Extract condenses it with HMAC, and Expand turns one secret into many independent keys, each tied to its own label. Why the transcript. Each traffic secret takes the hash of every message so far, so both sides get the same keys only if they saw the same messages. The Handshake and Master secrets take no transcript, which is why a flipped byte leaves them alone and changes every traffic secret.
In practiceWith SSLKEYLOGFILE set, browsers and curl write these traffic secrets to a file that Wireshark uses to decrypt your own capture.
DefenceNever enable key logging in production, and treat any key log file as a secret. Give applications keying material through the exporter (exporter_
Sealed records, numbered nonces
Each traffic secret yields a 16-byte AES key and a 12-byte IV. Every record is sealed with AES-128-GCM, its nonce the IV XORed with the record's sequence number, so no nonce repeats and none is sent. The 5-byte header is authenticated too, and the real content type travels inside the encryption.
- Traffic secret
- write_key
- write_iv
The maths
write_key = HKDF-Expand-Label(secret, "key", "", 16), write_iv = HKDF-Expand-Label(secret, "iv", "", 12)
nonce = write_iv ⊕ (032 ‖ seq64), AAD = 17 03 03 ‖ uint16(length)
length = |content| + 1 + |padding| + 16
Why this nonce. GCM breaks if a nonce repeats under one key. A 64-bit counter that starts at 0 for each key can't repeat, and since both sides count records, the nonce is never sent. Why the header is authenticated. It's the additional data, so a changed length or type fails the tag. Why the type is inside. Every protected record says 23 (application_data) on the outside, so an observer can't tell a handshake message or an alert from data. The receiver strips trailing zeros, and the last non-zero byte is the real type.
In practiceA record carries at most 16 KB. AES-GCM allows about 224.5 full-size records per key (RFC 8446 section 5.5), so long connections send a KeyUpdate and restart the count under a new key.
DefenceKeep one sequence number per direction, treat any authentication failure as fatal, and never reset a counter under the same key.
A chain, a signature, a MAC
A key exchange alone proves nothing about who is on the other end. So the server sends a certificate chain and signs the handshake hash with the leaf's private key. The client checks each signature, the dates, that only CAs issue, the name, and that the chain ends at a root it trusts.
The chain below is made in this tab with ECDSA P-256 keys: a fictional root, an issuing CA and a leaf for www.shop.example and *.shop.example. Its certificates carry only the fields validation needs, where real ones are X.509.
Issuing the certificates…
Simplified format, for teaching (it isn't X.509): subject, issuer, validity, the CA flag and path length, the names, the public key, then the issuer's ECDSA signature over all of them.
- Not validated yet.
The maths
signed = 0x20 × 64 ‖ "TLS 1.3, server CertificateVerify" ‖ 0x00 ‖ Transcript-Hash(ClientHello...
finished_
verify_
What the signature binds. The transcript hash covers both hellos, the encrypted extensions and the certificate. Signing it ties the certificate's key to this one handshake. A changed byte or a swapped key share would need the server's private key to sign again. The 64 spaces and the context string keep this signature apart from a client's and from other protocols. What the Finished adds. It's a MAC under a key only the two ends can derive, proving the server completed this very key exchange. The client sends one back the same way.
In practiceBrowsers also check Certificate Transparency, revocation, key usage and name constraints. Certificate lifetimes are shrinking: CA/Browser Forum ballot SC-081 (2025) steps the maximum down to 47 days by 2029, so renewing by hand is going to hurt lol.
DefenceAutomate renewal with ACME, always serve the intermediate, publish CAA records, and watch Certificate Transparency logs for certificates you didn't request.
Readable, encrypted, and in between
TLS 1.3 encrypts more of the handshake than any version before it, but not all of it. Everything up to and including the ServerHello travels in the clear, and every record header shows its length. Below is the whole handshake as an observer on the path sees it, and what an operator can do about the rest.
In TLS 1.2 the Certificate message wasn't encrypted, so anyone on the path could read which certificate, and so which site, a connection used. TLS 1.3 encrypts everything after the ServerHello, the certificate included. What still shows is the ClientHello (with the server name and the offered protocols), the ServerHello, and the size and timing of every record.
With ECH the client encrypts a whole inner ClientHello to a key the provider publishes in DNS, inside an outer ClientHello that names only the provider. The model builds that outer hello with a real X25519 key and a payload of random bytes at about the right length. The page doesn't run HPKE.
| From | Carries | Record | Readable | Encrypted |
|---|
Press Count the bytes to tally what an observer can read.
Three answers a server might send, sealed for real under this handshake's server application key. The observer sees only lengths.
| Answer | Content | Padding | Record |
|---|
Score 0 of 100.
The maths
readable = ∑ (plaintext records) + 5 × (protected records), encrypted = ∑ (protected record − 5)
padded to a multiple of B: |inner| = ⌈(n + 1) / B⌉ × B, fixed at B: every record is 5 + B + 16 bytes (while n + 1 ≤ B)
Why lengths leak. A protected record is its content plus 22 bytes (5 of header, 1 of type, 16 of tag). So an observer can tell a 25-byte "yes" from a 24-byte "no" without decrypting. What padding buys. Zeros inside the encryption round the content up, so the observer learns only which block of B bytes the length fell in. What stays visible regardless. Timing, the number of records and the plaintext hellos. ECH hides the name, though everyone can still see that a connection happened.
In practiceNetworks that filter or log by name read the server_name in the ClientHello. Encrypted Client Hello, now in major browsers and CDNs, moves it into an encrypted inner hello keyed from the site's DNS HTTPS record.
DefenceTurn on ECH where your provider supports it, send HSTS, keep TLS 1.2 to forward-secret AEAD suites, and pad responses whose size gives away their content.