Introduction
https://yuhi-sa.github.io/en/posts/20260614_cryptography_roadmap/1/ listed “a detailed walkthrough of the TLS 1.3 handshake” as a placeholder for a future article. This article implements it. TLS 1.3 is a protocol built by combining pieces already covered in earlier articles: the ECDH key exchange from https://yuhi-sa.github.io/en/posts/20260702_elliptic_curve_cryptography/1/, the AES-GCM cipher from https://yuhi-sa.github.io/en/posts/20260703_aes_symmetric_crypto/1/, and the ECDSA/EdDSA signatures from https://yuhi-sa.github.io/en/posts/20260704_digital_signature/1/. Here we trace concretely how those pieces combine into a secure session key, via the HKDF key-derivation function’s derivation and Python implementation.
The TLS 1.3 Handshake at a Glance (1-RTT)
TLS 1.2 and earlier required two round trips (2-RTT) to agree on keys; TLS 1.3 compresses this to one round trip (1-RTT), thanks to an “optimistic” design where the client sends a candidate key-exchange public key in its very first message.
| Step | Sender | Content |
|---|---|---|
| 1 | Client → Server | ClientHello: supported cipher suites, an ECDHE public key (key_share), and supported groups (e.g., X25519) |
| 2 | Server → Client | ServerHello: returns its ECDHE public key. At this point both sides can compute the shared ECDHE secret |
| 3 | Server → Client | {EncryptedExtensions, Certificate, CertificateVerify, Finished}: everything from here on is encrypted with the handshake traffic key |
| 4 | Client → Server | Finished: client-side handshake completion notice |
| 5 | Both | Switch to application traffic keys and begin communication |
Key exchange completes as of step 2, so the client can start preparing encrypted application data without waiting for step 3 (on resumption, TLS 1.3 also offers a 0-RTT mode that shaves off one more round trip; its replay-attack risk is covered in the “Edge Cases” section later in this article).

ECDHE Key Exchange: X25519
The key exchange TLS 1.3 mandates is ECDHE (Ephemeral Elliptic-curve Diffie-Hellman), and most implementations use Curve25519 (X25519). As covered in https://yuhi-sa.github.io/en/posts/20260702_elliptic_curve_cryptography/1/, client and server each generate an ephemeral key pair and derive the same shared secret from their own private key and the other’s public key via scalar multiplication.
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
client_priv = X25519PrivateKey.generate()
server_priv = X25519PrivateKey.generate()
shared_secret_client = client_priv.exchange(server_priv.public_key())
shared_secret_server = server_priv.exchange(client_priv.public_key())
assert shared_secret_client == shared_secret_server
Output:
client-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
server-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
match: True
Both sides independently computed identical shared secrets. But this shared secret isn’t used directly as an encryption key. TLS 1.3 runs it through a chain of HKDF key-derivation calls to safely derive several purpose-specific keys.
HKDF: Extract-and-Expand Key Derivation (RFC 5869)
HKDF has two stages.
HKDF-Extract: extracts a statistically uniform pseudorandom key (PRK) from the input key material (IKM).
\[ \text{PRK} = \text{HMAC-Hash}(\text{salt}, \text{IKM}) \tag{1} \]HKDF-Expand: generates the required length of output key material (OKM) from the PRK and context info.
\[ T(0) = \varnothing, \qquad T(i) = \text{HMAC-Hash}(\text{PRK},\ T(i-1) \Vert \text{info} \Vert i) \tag{2} \] \[ \text{OKM} = T(1) \Vert T(2) \Vert \cdots \quad \text{(truncated to the first L bytes)} \tag{3} \]Python implementation:
import hmac, hashlib
def hkdf_extract(salt, ikm, hash_len=32):
if not salt:
salt = b"\x00" * hash_len
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, length, hash_len=32):
n = -(-length // hash_len) # ceiling division
t = b""
okm = b""
for i in range(1, n + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
return okm[:length]
Numerical Verification: Matching RFC 5869 Test Vectors
Using RFC 5869 Test Case 1 (fixed salt/IKM/info, 42-byte output), we compared the from-scratch implementation above against cryptography.hazmat.primitives.kdf.hkdf.HKDF.
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
salt = bytes.fromhex("000102030405060708090a0b0c")
ikm = bytes([0x0b] * 22)
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
prk_scratch = hkdf_extract(salt, ikm)
okm_scratch = hkdf_expand(prk_scratch, info, 42)
hkdf_lib = HKDF(algorithm=hashes.SHA256(), length=42, salt=salt, info=info)
okm_lib = hkdf_lib.derive(ikm)
OKM (scratch) : 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
OKM (cryptography): 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
match: True
The from-scratch implementation and the standard library agree exactly. With this verified HKDF as a foundation, we build TLS 1.3’s own key schedule on top of it.
The TLS 1.3 Key Schedule (RFC 8446 §7.1)
TLS 1.3 defines a thin wrapper around HKDF called HKDF-Expand-Label that attaches a label, and chains it repeatedly to derive keys in stages.
HkdfLabel is a structured byte string packing the length, the label string ("tls13 " + label), and context (usually a transcript hash of prior messages). The key schedule proceeds as follows:
0
|
v
PSK -> HKDF-Extract = Early Secret
|
+-----> Derive-Secret(., "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret
|
+-----> Derive-Secret(., "c hs traffic", transcript) = client_handshake_traffic_secret
+-----> Derive-Secret(., "s hs traffic", transcript) = server_handshake_traffic_secret
For a normal full handshake without a pre-shared key (PSK), the Early Secret’s input IKM is a zero-filled byte string.
import struct
def hkdf_expand_label(secret, label, context, length):
full_label = b"tls13 " + label
hkdf_label = (struct.pack(">H", length) + bytes([len(full_label)]) + full_label
+ bytes([len(context)]) + context)
return hkdf_expand(secret, hkdf_label, length)
def derive_secret(secret, label, messages_hash):
return hkdf_expand_label(secret, label, messages_hash, 32)
zero_key = b"\x00" * 32
empty_hash = hashlib.sha256(b"").digest()
early_secret = hkdf_extract(b"\x00", zero_key)
derived_early = derive_secret(early_secret, b"derived", empty_hash)
handshake_secret = hkdf_extract(derived_early, shared_secret_client)
transcript_hash = hashlib.sha256(b"ClientHello||ServerHello (mock transcript)").digest()
client_hs_traffic_secret = derive_secret(handshake_secret, b"c hs traffic", transcript_hash)
server_hs_traffic_secret = derive_secret(handshake_secret, b"s hs traffic", transcript_hash)
client_write_key = hkdf_expand_label(client_hs_traffic_secret, b"key", b"", 16) # AES-128-GCM key
client_write_iv = hkdf_expand_label(client_hs_traffic_secret, b"iv", b"", 12)
Output:
handshake_secret : a2b298340a3f2cca87265e560a30a34d83101fc756b17d17dc249de535c2020e
client_hs_traffic_secret : 0430d76b951a157b85dd83362dc3c4fca59f8b861de863bd20a78365a898e702
server_hs_traffic_secret : 3056eba25fdda8d7a9630e4f2de5d6f37c48adb19301a4c7ccd6966398cba60b
client_write_key (16B) : 6091cfaa9bb4cedc772cef8aea5e5a93
client_write_iv (12B) : 025b4bcfb3454a951f3c9ab9
From a single X25519 shared secret, chained HKDF calls derived several purpose-specific keys — separate handshake traffic secrets for client and server, and from those, an encryption key and IV. Including the transcript hash as key-derivation input means that any tampering with the handshake messages changes every subsequent derived key, cryptographically guaranteeing the integrity of the entire handshake.
The Road to Master Secret: Why Derive-Secret(., "derived", "") Appears Twice
The code above stopped at the Handshake Secret, but RFC 8446’s key schedule goes one step further: it feeds 0 into one more HKDF-Extract to derive the Master Secret, from which the keys that protect application traffic and session resumption ultimately come.

Notice that Derive-Secret(., "derived", "") appears twice in the diagram. This isn’t decorative — it’s a ritual step that “consumes” the previous stage’s secret key material before handing it to the next HKDF-Extract. If this were skipped and the Early Secret were fed directly into the next Extract’s salt, a simple key-derivation relationship would remain between the Early Secret and the Handshake Secret, making compromise of one more likely to propagate to the other. Inserting one buffer step with the "derived" label and an empty context gives each stage’s secret its own independent namespace (domain separation).
We ran the derivation from Early Secret all the way to Master Secret in Python and verified that all nine resulting secrets are indeed distinct.
# handshake_secret, client_hs_traffic_secret, etc. continue from the previous code block
derived_hs = derive_secret(handshake_secret, b"derived", empty_hash)
master_secret = hkdf_extract(derived_hs, zero_key)
# transcript hash for the full handshake (ClientHello through server Finished)
transcript_hash_full = hashlib.sha256(
b"ClientHello||ServerHello||EE||Cert||CertVerify||Finished (mock full transcript)"
).digest()
client_app_traffic_secret_0 = derive_secret(master_secret, b"c ap traffic", transcript_hash_full)
server_app_traffic_secret_0 = derive_secret(master_secret, b"s ap traffic", transcript_hash_full)
exporter_master_secret = derive_secret(master_secret, b"exp master", transcript_hash_full)
# resumption_master_secret uses the transcript hash through the client's Finished message
transcript_hash_client_fin = hashlib.sha256(
b"ClientHello||ServerHello||EE||Cert||CertVerify||Finished||client Finished (mock)"
).digest()
resumption_master_secret = derive_secret(master_secret, b"res master", transcript_hash_client_fin)
Output:
master_secret : 7c4e46f4163582d097b9ae23e62b2beb16004a4174f06df647c23ed90c4fc822
client_app_traffic_secret_0 : 4ba696487c18eeaef89e7838ceb044b27450a6188a536e7d66ec1df10596ef37
server_app_traffic_secret_0 : ff76ed58173eebe00c72708025470a600504a7026fec3449310178516d539009
exporter_master_secret : cfeb72812aa3d39dd2762b2702f540e5580faebd785b4e40c976b7e1baed9a0f
resumption_master_secret : 792f713039b2510466594beea57728390a0ffceed6f35fe032b06a0aa119aff3
all 9 derived secrets pairwise distinct : True
The Early Secret, the Handshake Secret, the two traffic secrets branching from it, the Master Secret, and the four secrets branching from that — nine values in total — all came out pairwise distinct. Reusing a single HMAC-SHA256 primitive throughout, yet getting a cryptographically independent set of keys just by varying the label and context, is the core idea behind HKDF-based key scheduling.
Record Protection via AEAD: Encrypting and Decrypting with Derived Keys
We confirmed the derived client_write_key / client_write_iv can actually encrypt and decrypt a handshake record with AES-128-GCM.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aead = AESGCM(client_write_key)
plaintext = b"Finished message (mock TLS 1.3 handshake record)"
aad = b"\x17\x03\x03\x00\x50" # mock TLSCiphertext record header as AAD
ciphertext = aead.encrypt(client_write_iv, plaintext, aad)
recovered = aead.decrypt(client_write_iv, ciphertext, aad)
assert recovered == plaintext
decrypted matches : True
We confirmed the end-to-end flow actually works, from ECDHE key exchange through the HKDF key schedule to AEAD encryption. The nonce-uniqueness requirement for AEAD discussed in https://yuhi-sa.github.io/en/posts/20260703_aes_symmetric_crypto/1/ is met in real TLS 1.3 by combining client_write_iv with a per-record sequence number (this article handles only a single record for simplicity).
Edge Case: The 0-RTT Replay Attack Risk
TLS 1.3 has a 0-RTT mode that lets a client send application data with zero round trips, using a PSK obtained from an earlier connection (distributed via NewSessionTicket). The speed benefit is real, but 0-RTT is also the one mode RFC 8446 itself flags explicitly: “early data … is vulnerable to replay attacks.”
The reason lies in the key-derivation structure. The 0-RTT client_early_traffic_secret is determined solely from the PSK and the transcript hash of the ClientHello — no fresh randomness from the server (the ServerHello’s random) ever mixes into it. Unlike the keys that wait for ServerHello (Handshake Secret onward), if an on-path attacker simply captures the entire ClientHello + early_data packet, they can resend that exact byte string to the server multiple times without ever decrypting it, and the server has no cryptographic means of telling the replay apart from the original, legitimate delivery.
We confirmed this property in Python: derive a 0-RTT key from a fixed PSK (standing in for a ticket issued by a prior session), then “deliver” the identical ciphertext twice.
resumption_psk = hashlib.sha256(b"mock-resumption-psk-from-prior-session-ticket").digest()
early_secret_0rtt = hkdf_extract(b"\x00", resumption_psk)
ch1_transcript_hash = hashlib.sha256(b"ClientHello (mock, includes PSK identity + binder)").digest()
client_early_traffic_secret = derive_secret(early_secret_0rtt, b"c e traffic", ch1_transcript_hash)
early_write_key = hkdf_expand_label(client_early_traffic_secret, b"key", b"", 16)
early_write_iv = hkdf_expand_label(client_early_traffic_secret, b"iv", b"", 12)
early_aead = AESGCM(early_write_key)
early_data_plaintext = b"GET /api/withdraw?amount=100 HTTP/1.1" # worst case: a non-idempotent request
early_data_aad = b"\x17\x03\x03\x00\x26"
# The client sends this ciphertext exactly once. An attacker captures it as-is.
captured_ciphertext = early_aead.encrypt(early_write_iv, early_data_plaintext, early_data_aad)
# The attacker resends the captured ciphertext, byte-for-byte, as a second delivery.
decrypted_first_delivery = early_aead.decrypt(early_write_iv, captured_ciphertext, early_data_aad)
decrypted_replayed_delivery = early_aead.decrypt(early_write_iv, captured_ciphertext, early_data_aad)
Output:
client_early_traffic_secret : 223b2d85a03b9384629dd8c5e991db215c52f7fa8e056b5098b0541bc92f0951
early_write_key (16B) : 41383fd7fa04fa98005c313ee6d40468
early_write_iv (12B) : e5446faa95e19b2bf065d5c0
decrypted (1st legit delivery) : b'GET /api/withdraw?amount=100 HTTP/1.1'
decrypted (2nd replayed delivery): b'GET /api/withdraw?amount=100 HTTP/1.1'
identical plaintext both times : True
Decrypting the same ciphertext twice produces exactly the same plaintext both times — AEAD guarantees “this ciphertext hasn’t been tampered with,” but it has no concept of “have I seen this exact message before.” If the request isn’t idempotent (a funds-transfer API, say), a replay translates directly into real damage.
Given this, RFC 8446 Section 8 asks implementers to adopt mitigations such as:
| Mitigation | Summary | Trade-off |
|---|---|---|
| Single-use tickets | Consume each NewSessionTicket for exactly one resumption | Requires server-side tracking of ticket consumption (needs shared storage in distributed deployments) |
| ClientHello recording | Cache a digest of recently seen ClientHellos and reject duplicates | Doesn’t protect across the cache window or across multiple front-end instances |
obfuscated_ticket_age window | Reject requests whose elapsed time since ticket issuance is implausibly short or long | Can misfire on clock skew or legitimate retries |
| Disable or restrict 0-RTT | Allow 0-RTT only for idempotent GETs, never for state-changing requests | Gives up some of 0-RTT’s speed benefit |
These aren’t mutually exclusive; in practice, operators typically combine shared ticket-consumption caches at the CDN/load-balancer layer with idempotency checks at the application layer. Fadul, Ramadass, and Gismalla (2023) compare implementation-level mitigations for the 0-RTT replay attack at IEEE ICEESE (see References).
Key Differences from TLS 1.2
| Aspect | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Handshake round trips | 2-RTT | 1-RTT (0-RTT on resumption) |
| Key exchange | RSA key transport or (EC)DHE | (EC)DHE mandatory (forward secrecy enforced) |
| Symmetric cipher | CBC mode etc. (padding-oracle risk) | AEAD mandatory (AES-GCM / ChaCha20-Poly1305) |
| Key derivation | TLS-specific PRF | HKDF (RFC 5869-compliant) |
| Deprecated features | RC4, SHA-1, static RSA key exchange | Removed |
The biggest design change is that RSA key transport — where compromising the server’s private key retroactively decrypts all past traffic — is gone, and forward secrecy via (EC)DHE is now mandatory. The “use a fresh ephemeral key every session” design covered in https://yuhi-sa.github.io/en/posts/20260614_diffie_hellman/1/ is no longer optional in TLS 1.3 — it’s required.
Latest Development: The Shift to Hybrid Post-Quantum Key Exchange
This article’s ECDHE key exchange relies on the hardness of the discrete-logarithm problem over X25519 (an elliptic curve). Once a sufficiently capable quantum computer exists, Shor’s algorithm breaks that hardness assumption. This transition is not a distant theoretical concern — it is already rolling out in production in major browsers and CDNs.
- In August 2024, NIST formally standardized the lattice-based key-encapsulation mechanism ML-KEM (derived from CRYSTALS-Kyber) as FIPS 203 (Federal Register, August 14, 2024).
- Google Chrome enabled the draft hybrid key exchange
X25519Kyber768Draft00by default in version 124 (April 2024), then switched to the standardizedX25519MLKEM768in version 131 (November 2024). - Per Cloudflare’s blog post “State of the post-quantum Internet in 2025” (Westerbaan, October 28, 2025), over 50% of human-initiated HTTPS traffic passing through Cloudflare was already protected by hybrid post-quantum key exchange as of late October 2025.
The key point here is that this doesn’t replace the TLS 1.3 key schedule — it just adds one new way to build the shared secret that feeds into it. In the IETF’s hybrid key exchange design (RFC 9954 — the basis for Chrome’s and Firefox’s X25519MLKEM768), the ECDHE exchange (X25519) and the PQC exchange (ML-KEM-768) run independently, and the two resulting shared secrets are simply concatenated into a single byte string, which is fed directly into the (EC)DHE input position of the HKDF-Extract call described earlier in this article.
In other words, the only thing that changes is that the IKM fed into the HKDF-Extract that derives the Handshake Secret goes from one shared secret to two concatenated ones; everything from Early Secret onward — the domain separation via Derive-Secret, the transition to Master Secret, the derivation of traffic keys — reuses exactly the structure verified in this article. The design philosophy is an AND-composition: “as long as either ECDHE or ML-KEM remains secure, the hybrid as a whole remains secure.” Even if ECDHE eventually falls to a quantum computer, the session stays secure as long as the hardness of the ML-KEM lattice problem hasn’t been broken (and vice versa). The fact that this migration doesn’t require rewriting the key schedule itself is a direct consequence of TLS 1.3’s HKDF-based key schedule having been designed from the start to “combine multiple inputs into one secret” — a concrete example of the extensibility we’ve seen throughout this article.
Observing This in Practice
# Observe an actual TLS 1.3 handshake
openssl s_client -connect example.com:443 -tls1_3 -msg
# Check the negotiated cipher suite
openssl s_client -connect example.com:443 -tls1_3 2>/dev/null | grep "Cipher is"
To inspect decrypted packets in Wireshark, set the SSLKEYLOGFILE environment variable to have your browser or OpenSSL client dump its key log — you’ll see the same kinds of keys derived in this article (e.g., CLIENT_HANDSHAKE_TRAFFIC_SECRET) from real traffic.
Related Articles
- Cryptography Roadmap: Classical Ciphers, Symmetric Keys, RSA, Diffie-Hellman, Elliptic Curves, Hashing, Signatures, and TLS in Python - This article implements one of this hub’s “planned for the future” placeholders.
- Elliptic Curve Cryptography (ECC): Math and Python Implementation - The theoretical foundation for this article’s ECDHE key exchange (X25519).
- SHA-256 and HMAC in Python - Explains the construction and length-extension-attack resistance of the HMAC-SHA256 used repeatedly inside this article’s HKDF.
- Post-Quantum Cryptography in Python: Learning With Errors (LWE) and Regev Encryption - Provides context for how this article’s ECDHE handshake could eventually migrate to Kyber-based key exchange.
- AES / ChaCha20 Symmetric Cryptography: Theory and Python Implementation - Details the AEAD encryption (AES-GCM) that uses the keys derived here.
- Digital Signatures (ECDSA / EdDSA / RSA-PSS): Theory and Python Implementation - The signature schemes used in the handshake’s CertificateVerify step.
- Diffie-Hellman Key Exchange: Theory and Implementation - The foundational (pre-elliptic-curve) key exchange and forward-secrecy concept underlying ECDHE.
- OAuth 2.0 / OpenID Connect: Theory and Implementation - An authorization protocol built on top of TLS, connected here through its own use of public-key cryptography for JWT signature verification.
References
- Rescorla, E. (2018). The Transport Layer Security (TLS) Protocol Version 1.3. RFC 8446.
- Krawczyk, H., & Eronen, P. (2010). HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869.
- Thomson, M., & Turner, S. (2018). Illustrated TLS 1.3 Connection (tls13.xargs.org).
- Rescorla, E., et al. (2018). Example Handshake Traces for TLS 1.3. RFC 8448 (test vectors).
- Fadul, M. E. A., Ramadass, S., & Gismalla, M. S. M. (2023). Replay Attack in TLS 1.3 0-RTT Handshake: Countermeasure Techniques. 2023 IEEE 6th International Conference on Electrical, Electronics and System Engineering (ICEESE). DOI: 10.1109/ICEESE56169.2023.10278190.
- Westerbaan, B. (2025, October 28). State of the post-quantum Internet in 2025. The Cloudflare Blog. https://blog.cloudflare.com/pq-2025/
- NIST (2024). FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard. Federal Register notice, August 14, 2024.
- Stebila, D., Fluhrer, S., & Gueron, S. (2026). Hybrid Key Exchange in TLS 1.3. RFC 9954 (Informational).