Internet Protocols · TLS
The TLS 1.3 Key Schedule: Extract, Expand, and Why Order Matters
How HKDF turns one shared secret into six traffic secrets, what the transcript hash does at each stage, and why early secrets are derived before the peer is authenticated.
A TLS 1.3 connection needs more secrets than the key exchange produces. It needs separate keys for handshake records in each direction, separate keys for application records in each direction, a secret for the exporter interface, a secret for resumption tickets, and a secret for the finished messages. The key exchange yields one shared value. The key schedule is the structure that expands it, and its shape is a deliberate choice about what each secret is allowed to prove.
Extract then expand, twice
HKDF is two functions. Extract takes a salt and input keying material and produces a fixed-length pseudorandom key. Expand takes that key, an optional context string, and a length, and produces output of that length. TLS 1.3 uses both, and uses Expand several times with different labels to produce secrets that cannot be related to each other.
The extract step is where the first secret enters. The salt is a string of zeros of hash length and the input keying material is the PSK, or a string of zeros when no PSK is offered. The result is the early_secret. Because Extract is HMAC applied with the salt as key, the operation is not a plain hash: the input is mixed in as message data, so its structure does not leak through length extension or similar properties.
From early_secret, the schedule proceeds in a fixed sequence of stages. Each stage derives a salt from the previous stage’s secret with the label “derived” and an empty context, extracts the new input keying material under that salt, and then expands the result with a label describing its purpose. The stage boundaries are not arbitrary; each one corresponds to a moment when new information becomes available and must be bound into everything downstream.
def hkdf_expand_label(secret, label, context, length):
info = (length.to_bytes(2, "big")
+ bytes([len(b"tls13 " + label)]) + b"tls13 " + label
+ bytes([len(context)]) + context)
return hkdf_expand(secret, info, length)
def derive_secret(secret, label, messages):
return hkdf_expand_label(secret, label, transcript_hash(messages), hash_len)
def add_secret(current, ikm):
# the "derived" salt keeps one stage's secret from serving as the next
salt = derive_secret(current, b"derived", b"")
return hkdf_extract(salt, ikm)
The doubled use of a label is not a mistake. The extract step’s salt comes from a secret, not from a label, so a stage’s output is unrelated to the value it was fed; the expand step uses a label plus the transcript hash as context so that the result is bound to the handshake state. Two connections with identical key exchange output but different ClientHello contents produce different traffic secrets at every stage that takes a transcript, because the context differs even when the input secret does not.
The stages, in the order the protocol reaches them
The first stage derives the client’s early traffic secret from the early_secret and the transcript hash of the ClientHello. This is the only secret available before the server has said anything, which is exactly why early data is dangerous: it is encrypted under a key that the server has not yet authenticated itself for, and any server that holds the corresponding ticket can decrypt it, including a server that is not the one the client intended to reach.
The second stage mixes in the shared secret. Extract(early_secret, "derived") produces a salt, and Extract(salt, shared_secret) produces the handshake_secret. From it come the client and server handshake traffic secrets, each bound to the transcript through ServerHello. The client’s handshake key is used to encrypt its Finished; the server’s encrypts the whole flight after ServerHello. Both are distinct, so a record captured in one direction cannot be replayed in the other.
The third stage produces the master_secret. It is derived by extracting the handshake secret with a salt derived from it, and it is used only to derive further secrets, never to protect records directly. The reason for a master secret that protects nothing is that the application traffic secrets must survive the handshake’s completion without being tied to the handshake keys. If application keys were derived from the handshake traffic secrets, an attacker who recovered a handshake key would learn something about the application keys; the extra extraction layer breaks that relationship.
Application traffic secrets are derived from the master secret with the transcript hash through the server’s Finished. Because that hash includes the certificate chain and the CertificateVerify signature, the application keys are bound to the authentication decision. A connection that completed with a forged certificate would produce application secrets bound to the forgery, which is why the client must reject before sending application data rather than after.
The final stage produces the exporter master secret and the resumption master secret. The exporter secret supports the exporter interface, which lets an application derive channel-bound keys without touching the traffic keys. The resumption secret, bound to the transcript through the client’s Finished, is what a ticket’s PSK is derived from, so a ticket issued on one connection cannot be replayed on a connection whose transcript differs.
Why the order is not reorderable
Each derivation consumes the transcript hash at a specific point. The handshake secrets are bound through ServerHello; the application secrets through the server’s Finished. If the client were to derive application secrets earlier, they would not cover the server’s certificate, and an attacker who could modify the certificate in flight would still get the client to derive the same application keys.
The dependence runs the other way as well. A Finished message is an HMAC over the transcript, keyed by the finished key derived from the sender’s traffic secret. Verifying it proves the peer computed the same secret, which proves it saw the same transcript. The key schedule and the transcript hash are mutually reinforcing: the secrets depend on the hash, and the hash is authenticated by a key derived from the secrets.
There is also a structural constraint from the early secret. Early data keys must exist before the server’s first flight arrives, so the schedule must be able to produce them from the ClientHello transcript alone. Once that requirement fixes the first stage, the rest follows: the shared secret can only enter after ServerHello, so the handshake secret must come second, and application secrets cannot precede authentication.
Sizes, labels, and the mistakes that break interop
All secrets are the hash output length of the negotiated suite: 32 bytes for SHA-256, 48 for SHA-384. The traffic keys are shorter: 16 bytes for AES-128-GCM, 32 for AES-256-GCM and ChaCha20-Poly1305. The IVs are always 12 bytes. An implementation that uses one length for both will produce a connection that fails at the first record, with an error that points at the AEAD rather than at the schedule.
The label encoding is rigid. Each HKDF-Expand-Label call encodes the requested output length in two bytes, then the label itself as a length-prefixed string whose total, including the “tls13 ” prefix, may not exceed 255 bytes, then the context as another length-prefixed string. The labels are fixed strings from the specification: “derived”, “c e traffic”, “e exp master”, “c hs traffic”, “s hs traffic”, “c ap traffic”, “s ap traffic”, “exp master”, “res master”, “finished”, “key”, “iv”, “traffic upd”, and the two binder labels “ext binder” and “res binder”. A single wrong character produces a completely different key, and the resulting failure is indistinguishable from a corrupted record.
The “derived” label is used with an empty context in the extract steps, which is the detail most often gotten wrong. It exists so that the salt for the next stage is a value distinct from the secret it was computed from. Without it, the schedule would use one stage’s secret directly as the salt for extracting the next, and the two derivations would be linked in a way the design avoids: the salt and the input keying material would come from the same lineage with no separating step. The extra expansion costs one hash invocation per stage and removes that linkage.
Nothing in the schedule depends on the record layer, but everything in the record layer depends on it. The 12-byte IVs produced here are the values combined with sequence numbers to build per-record nonces, and the traffic keys are the ones that the cipher’s data limits apply to. A key update replaces the application traffic secret with one derived from the current secret and a fresh label, as the key update rules describe, leaving the handshake secrets and the resumption secret untouched, so a ticket issued before an update remains usable afterwards.