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.

Advertisement

The record layer, byte by byte

One protected TLS 1.3 record, as it crosses the wire0x17outer type0x03 0x03legacy versionlength2 bytesencrypted_recordAEAD output, tag includedthese 5 bytes are the AEAD additional datadecryptcontenthandshake, alert or application bytesreal type1 bytezero paddingoptional, any lengthTLSInnerPlaintext: find the real type by scanning back past the zerosnonce = write_iv XOR seq64-bit counter per direction, never sentkeys from the traffic secretHKDF-Expand-Label key and ivWrong order, a dropped record or a replay changes seq, so authentication fails.
A protected record: a fixed outer header, then AEAD ciphertext hiding the real content type and padding.

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.

Advertisement

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())
# 6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba

From 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

  1. 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.
  2. Capture and decrypt one TLS 1.3 connection to a test server with a key log, and identify every record's real type.
  3. 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.
  4. Measure your resumption rate in production; a rate near zero usually means a binder or ticket-key problem.
  5. Check that long-lived connections perform KeyUpdate well before the AES-GCM record limit.
  6. Search build and container images for SSLKEYLOGFILE and remove it from anything that is not a lab.
Key takeaway: TLS 1.3 internals come down to a few exact rules: records hide their real type and padding inside an AEAD whose additional data is the 5-byte header, nonces are the write IV XORed with an implicit sequence number that resets on every key change, and every secret comes from HKDF-Expand-Label over a transcript hash. HelloRetryRequest substitutes a message_hash for the first ClientHello, the server random carries a downgrade sentinel, binders cover a truncated ClientHello, and KeyUpdate and exporters extend the same schedule after the handshake. Verify each step against RFC 8448 rather than trusting any diagram.