Most engineers meet TLS 1.3 as a diagram of handshake messages. That is enough to deploy it, but not to debug a handshake that fails only behind one firewall or explain why resumption never works. For that you need the byte-level rules: record layout, nonces, key derivation and the special cases added for compatibility.
This page is that layer, written so you can check each claim yourself. The message-by-message handshake, certificate validation and post-quantum key exchange are covered in TLS and SSL explained, and latency tuning in TLS handshake optimization; they are not repeated here. Every constant below was checked against the text of RFC 8446, and the key schedule code was run against the published values in RFC 8448, the TLS 1.3 example-handshake RFC, on 2026-10-02.
The record layer, byte by byte
Every TLS byte travels in records. A record has a 5-byte header: a content type, a legacy version and a 2-byte length. The content types are change_cipher_spec 20, alert 21, handshake 22 and application_data 23. Plaintext fragments are at most 2 to the 14th bytes, 16,384, and a protected record may be up to 256 bytes longer to allow for the tag and padding.
Once keys exist, every protected record is disguised as application data: outer type 23 and version 0x0303, whatever it really carries. The real type sits inside the encrypted payload. The plaintext fed to the AEAD is a TLSInnerPlaintext: the content, then one byte holding the real content type, then any number of zero bytes as padding. The receiver decrypts, then scans backwards from the end past the zeros; the first non-zero byte is the type. A payload that is all zeros is a protocol error. The additional authenticated data is exactly the 5-byte record header, so tampering with the length or type fails authentication.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def open_record(record: bytes, key: bytes, iv: bytes, seq: int):
header, body = record[:5], record[5:]
assert header[0] == 23 and header[1:3] == b"\x03\x03"
assert int.from_bytes(header[3:5], "big") == len(body)
nonce = bytes(a ^ b for a, b in zip(iv, seq.to_bytes(len(iv), "big")))
inner = AESGCM(key).decrypt(nonce, body, header) # AAD = header
i = len(inner) - 1
while i >= 0 and inner[i] == 0: # strip padding
i -= 1
if i < 0:
raise ValueError("unexpected_message: no content type")
return inner[i], inner[:i] # (real type, content)
Nonces, sequence numbers and AEAD limits
TLS 1.3 never sends a nonce. Each side keeps a 64-bit sequence number per direction, starting at zero and incremented per record. The nonce is that number, encoded big-endian and left-padded to the IV length, XORed with the static write IV derived from the traffic secret. The sequence number resets to zero every time the key changes: at the switch to handshake keys, to application keys and after each KeyUpdate.
Because the counter is implicit, a dropped, reordered or replayed record decrypts under the wrong nonce and fails authentication, which is why TLS needs an ordered transport. QUIC reuses the handshake and key schedule but protects packets with explicit packet numbers instead; see QUIC in depth. RFC 8446 also bounds how much one key may encrypt: for AES-GCM, about 2 to the 24.5 full-size records, roughly 24 million, per key while keeping a safe margin; for ChaCha20-Poly1305 the sequence number wraps before the limit is reached. Long-lived, high-volume connections must therefore rekey, which is what KeyUpdate is for.
The key schedule, checked against RFC 8448
All secrets come from one HKDF chain. Two primitives do the work. HKDF-Expand-Label wraps HKDF-Expand with a structured info field: a 2-byte output length, the label prefixed with the ASCII string "tls13 ", and a context, each length-prefixed. Derive-Secret is HKDF-Expand-Label with the transcript hash as context and the hash length as output size. The code below uses only the Python standard library and reproduces the first two values printed in RFC 8448's simple 1-RTT handshake.
import hashlib, hmac
def hkdf_extract(salt, ikm):
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, length):
out, block, i = b"", b"", 1
while len(out) < length:
block = hmac.new(prk, block + info + bytes([i]), hashlib.sha256).digest()
out += block
i += 1
return out[:length]
def hkdf_expand_label(secret, label, context, length):
full = b"tls13 " + label
info = (length.to_bytes(2, "big") + bytes([len(full)]) + full
+ bytes([len(context)]) + context)
return hkdf_expand(secret, info, length)
def derive_secret(secret, label, transcript_hash):
return hkdf_expand_label(secret, label, transcript_hash, 32)
early = hkdf_extract(bytes(32), bytes(32)) # no PSK: zeros in, zeros salt
print(early.hex())
# 33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a
derived = derive_secret(early, b"derived", hashlib.sha256(b"").digest())
print(derived.hex())
# 6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611baFrom here the chain continues as the RFC's diagram shows. The derived value is the salt for extracting the handshake secret from the ECDHE shared secret; the client and server handshake traffic secrets are Derive-Secret with the labels "c hs traffic" and "s hs traffic" over the transcript through ServerHello. The same steps produce the master secret and the application traffic secrets, labels "c ap traffic" and "s ap traffic", over the transcript through the server Finished. Keys and IVs are HKDF-Expand-Label of a traffic secret with labels "key" and "iv" and an empty context. With the SHA-384 suite, secrets and hashes become 48 bytes, the key is 32 bytes for AES-256 and the IV stays 12. Feed in the RFC 8448 inputs and every intermediate it prints should match; the first mismatch tells you which input is wrong.
The transcript hash and the HelloRetryRequest rule
Each Derive-Secret is bound to a hash of the handshake messages so far, the bytes of each message including its 4-byte handshake header but excluding record headers. That binding is why a modified handshake yields different keys and a failed Finished check.
One case breaks the simple rule. If the server cannot use any key share the client offered, it replies with a HelloRetryRequest: a ServerHello whose random field is the fixed value SHA-256 of the ASCII string "HelloRetryRequest", which begins CF 21 AD 74. The client sends a second ClientHello. To keep the transcript bounded and let a stateless server reconstruct it from a cookie, the first ClientHello is replaced in the transcript by a synthetic handshake message of type message_hash, 254, whose body is the hash of the first ClientHello.
HRR_RANDOM = hashlib.sha256(b"HelloRetryRequest").digest() # cf21ad74...
def transcript_after_hrr(ch1: bytes, hrr: bytes, ch2: bytes, *rest: bytes):
h = hashlib.sha256(ch1).digest()
synthetic = bytes([254, 0, 0, len(h)]) + h # message_hash header + body
return hashlib.sha256(synthetic + hrr + ch2 + b"".join(rest)).digest()Implementations that hash the first ClientHello directly derive different keys from the server and fail only on the retry path, which is exactly the path most test suites forget. Force it in testing by sending a key share only for a group the server does not support, while supported_groups still lists one it does.
Version negotiation and the downgrade sentinel
To get past servers and middleboxes that broke on unknown version numbers, TLS 1.3 freezes the old fields: ClientHello.legacy_version is 0x0303 and the real offer travels in the supported_versions extension, where TLS 1.3 is 0x0304. A server selects 1.3 by echoing 0x0304 in its own supported_versions extension.
That alone would let an attacker strip the extension and force TLS 1.2. The defence is in the server random. A TLS 1.3 server that negotiates TLS 1.2 sets the last 8 bytes of ServerHello.random to 44 4F 57 4E 47 52 44 01, ASCII DOWNGRD followed by 01; for TLS 1.1 or below the final byte is 00. Because the random is covered by the signatures, an attacker cannot remove it. A TLS 1.3 client that sees either value while negotiating an older version must abort with an illegal_parameter alert.
DOWNGRD = bytes.fromhex("444f574e475244")
def check_downgrade(server_random: bytes, negotiated: int, client_max: int):
if client_max >= 0x0304 and negotiated < 0x0304:
if server_random[-8:-1] == DOWNGRD and server_random[-1] in (0, 1):
raise ConnectionError("illegal_parameter: downgrade detected")
Middlebox compatibility mode
Field measurements before standardisation showed many middleboxes dropping connections that did not look like TLS 1.2 resumption. RFC 8446 therefore describes a compatibility mode, used by mainstream clients: the client sends a non-empty 32-byte legacy_session_id, the server echoes it, and each side sends a dummy change_cipher_spec record, the six bytes 14 03 03 00 01 01, around its first encrypted flight. Receivers ignore an unencrypted change_cipher_spec of that exact form during the handshake and must abort if one arrives encrypted.
The practical consequence: in a capture, a change_cipher_spec in a TLS 1.3 handshake is not a sign of TLS 1.2, and a server that echoes a different session ID than the client sent must be rejected.
Finished, CertificateVerify and PSK binders
Three messages prove possession of keys, and each has exact input rules. Finished carries an HMAC: the key is HKDF-Expand-Label of the sender's handshake traffic secret with label "finished", and the data is the transcript hash up to but not including the Finished itself.
CertificateVerify is a signature over a constructed block: 64 bytes of 0x20, the context string "TLS 1.3, server CertificateVerify" (or the client variant), a single 0x00 byte, then the transcript hash. The 64-byte prefix exists so a signature can never be confused with one over attacker-chosen data from an older protocol version.
A PSK offer for resumption carries a binder, which ties the PSK to this ClientHello. The binder key is Derive-Secret of the early secret with label "res binder" for resumption PSKs or "ext binder" for external ones, over an empty transcript. The binder is a Finished-style HMAC over the hash of a truncated ClientHello: every byte up to the binders list, with lengths computed as if the binders were present. Computing it over the whole message, or with wrong length fields, is the classic resumption bug: the server rejects every ticket and the client silently falls back to full handshakes.
After the handshake: tickets, KeyUpdate and exporters
The server can send NewSessionTicket messages at any time after the handshake. Each carries a ticket nonce, and the resumption PSK is HKDF-Expand-Label of the resumption master secret with label "resumption" and the ticket nonce as context. Distinct nonces give each ticket a distinct PSK, so tickets are not interchangeable.
KeyUpdate rekeys one direction: the next application traffic secret is HKDF-Expand-Label of the current one with label "traffic upd" and an empty context, and the sequence number restarts at zero. Its request_update field set to update_requested asks the peer to update its sending keys too. Endpoints use it before the AEAD limit and on long-lived connections as a matter of hygiene.
Exporters let applications derive keys bound to the session, for channel binding or for keying another protocol. TLS-Exporter is HKDF-Expand-Label of Derive-Secret of the exporter master secret with the application's label and an empty transcript, using the label "exporter" and the hash of the context value. Use the library's export API rather than any raw secret; for where TLS terminates and how that limits channel binding, see TLS for operators.
Worked example: decrypt your own connection
When a handshake fails or the application sees odd data, the fastest diagnosis is to decrypt a capture of your own test traffic. Most TLS libraries can log secrets in the NSS key log format; Python exposes it on the context.
import socket, ssl
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.keylog_filename = "/tmp/tls_keys.log" # lab only, never in production
with socket.create_connection(("example.com", 443)) as raw:
with ctx.wrap_socket(raw, server_hostname="example.com") as tls:
print(tls.version(), tls.cipher())
tls.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
tls.recv(4096)Capture the same run with tcpdump on port 443, then point Wireshark's TLS protocol preferences at the key log. The file holds one line per secret: CLIENT_HANDSHAKE_TRAFFIC_SECRET, SERVER_HANDSHAKE_TRAFFIC_SECRET, CLIENT_TRAFFIC_SECRET_0, SERVER_TRAFFIC_SECRET_0 and EXPORTER_SECRET, each followed by the client random and the secret in hex. Wireshark now shows EncryptedExtensions, the certificate chain and the application data. Check, in order: whether a HelloRetryRequest appears, which group and cipher suite were chosen, whether the server's certificate chain is complete, and which alert, if any, ended the connection. A key log file decrypts every session it covers, so treat it as a secret and delete it after the lab.
Failure modes
- Retry-path key mismatch: the transcript omits the message_hash substitution, so only clients that trigger HelloRetryRequest fail.
- Padding parsed wrongly: a receiver reads the type from the last byte without stripping zeros and misclassifies padded records.
- Sequence number not reset: after a key change the counter continues, so every following record fails authentication.
- Binder over the wrong bytes: every resumption attempt is rejected and the fleet quietly loses its resumption rate.
- Middlebox interference: a device that drops non-TLS-1.2-looking handshakes breaks clients with compatibility mode turned off.
- Downgrade check missing: a custom client accepts TLS 1.2 from an attacker who stripped supported_versions.
- Key logs left enabled: an SSLKEYLOGFILE variable set in a production image writes every session secret to disk.
What to do next
- Run the key schedule snippet and confirm it prints the two RFC 8448 values; extend it through the handshake secrets using the RFC's inputs.
- Capture and decrypt one TLS 1.3 connection to a test server with a key log, and identify every record's real type.
- Force a HelloRetryRequest in tests with a key share for an unsupported group plus a supported one in supported_groups, and confirm the handshake succeeds.
- Measure your resumption rate in production; a rate near zero usually means a binder or ticket-key problem.
- Check that long-lived connections perform KeyUpdate well before the AES-GCM record limit.
- Search build and container images for SSLKEYLOGFILE and remove it from anything that is not a lab.