Internet Protocols · TLS
The TLS Record Layer: Framing, Fragmentation, and Sequence Numbers
How application bytes become protected records: the five-byte header, the 16 KiB fragment limit, implicit sequence numbers, and how the AEAD nonce is assembled.
The handshake runs once and produces keys. The record layer runs for the life of the connection, and it is the part of TLS that every byte of application data passes through. It cuts the byte stream into fragments, encrypts each fragment under a per-direction secret, numbers them, and hands the result to TCP. Framing, counting, and authenticated encryption are all decided here, and most of the protocol’s operational behaviour follows from those three decisions.
A header that describes ciphertext rather than plaintext
Every record starts with a five-byte header: one byte of content type, two bytes of protocol version, and two bytes of length. In TLS 1.3 that header is not an honest description of the sender’s intent. For any protected record the type byte is 23, the value for application_data, no matter what the plaintext actually contains. The version bytes are fixed at 0x0303, the encoding for TLS 1.2, even when TLS 1.3 was negotiated. The length counts ciphertext, not plaintext. The true content type moves inside the encrypted payload, appended after the application bytes, and the receiver recovers it only after decryption succeeds.
The fixed values exist because the header is visible to everything on the path. A middlebox that has learned to expect 0x0303 in that field will keep working, and a passive observer learns nothing from a field that never varies. TLS 1.2 had the opposite arrangement: the header carried the real content type, so handshake records, alerts, and application data were distinguishable on the wire without any key. That distinction was useful for debugging and for network appliances, and it was also a fingerprinting surface.
Two size limits matter. A plaintext fragment may not exceed 2^14 bytes, which is 16384. The ciphertext record may not exceed 2^14 + 256 bytes, an allowance that covers the authentication tag, the encrypted type byte, and whatever padding the sender chose to add. The header itself is not encrypted. It is passed to the AEAD as associated data, so the receiver authenticates those five bytes exactly as it authenticates the payload: flipping a bit in the length field causes the tag check to fail rather than causing a misparse.
Fragmentation is the record layer’s only message boundary
TLS runs over a byte stream, and it preserves that model. A write of 100,000 bytes becomes at least seven records, since seven fragments of 16 KiB cover 114,688 bytes. Where the cuts fall is an implementation decision, not a protocol one. A reader on the other side cannot see them: a single read may return bytes from three records, or half of one, because the receiving implementation reassembles plaintext into a buffer before handing it to the application. Record boundaries carry no meaning above the record layer.
The size choice is a genuine trade. Each record costs 5 bytes of header plus a 16-byte tag for the common AEAD suites, so 21 bytes of overhead per record. At the 16 KiB maximum that is about 0.13 percent. At 100-byte records it is 21 percent, which is why an implementation that flushes every small write pays a real bandwidth penalty. The opposite policy, buffering until a fragment is full, adds delay to interactive traffic, and the delay is not bounded by anything in the protocol. Between those extremes sit two negotiated extensions: max_fragment_length from the TLS extensions specification and the later record_size_limit, which lets an endpoint advertise the largest record it is willing to receive. They exist mainly for constrained devices with small buffers, not for tuning throughput.
TLS 1.2 also offered compression at this layer, negotiated in the ClientHello. It was removed in TLS 1.3. Compressing attacker-influenced plaintext alongside secrets leaks the secrets through ciphertext length, the mechanism behind the CRIME and BREACH attacks. Removing the feature was cheaper than trying to make it safe, and the record layer became strictly a framing and protection layer.
Sequence numbers that never travel
Each direction of a connection has its own 64-bit counter. It starts at zero, increments by one for every record sent, and is never placed on the wire. The receiver keeps the same counter and uses its current value when it authenticates an incoming record, so a record that is dropped, duplicated, or reordered by an attacker cannot be replayed into a different position in the stream.
Implicit counting is safe only because the transport is ordered. TCP delivers bytes in order and retransmits what is lost, so the receiver can never face a gap or a duplicate at the record layer. DTLS, which runs over datagrams, cannot assume this and therefore carries an explicit epoch and sequence number in every record. The difference is a direct consequence of the transport, not a design preference.
The counter must not wrap. TLS 1.3 forbids reusing a sequence number with the same key, and a sender that reaches 2^64 records must perform a key update first. In practice the counter is not the binding constraint; the cryptographic limits discussed below are reached far earlier. A key update resets the counter to zero and installs a fresh secret, so the pair of key and counter stays unique.
TLS 1.2 with AES-GCM used a different construction. The nonce was 12 bytes assembled from a four-byte salt taken from the key block and an eight-byte explicit nonce written into each record, which made part of the nonce visible on the wire. The explicit value was redundant given the implicit counter, but it allowed a receiver to process records without maintaining state, and it made the reuse hazard easier to audit. TLS 1.3 dropped it in favour of the fully implicit construction.
Assembling the nonce for each record
AES-GCM and ChaCha20-Poly1305 both take a 96-bit nonce, a secret key, and associated data. TLS 1.3 derives a 12-byte write IV from the key schedule and combines it with the sequence number by exclusive-or, with the counter right-aligned in the last eight bytes.
def record_nonce(write_iv: bytes, seq: int) -> bytes:
# write_iv is 12 bytes, fixed for the lifetime of the key
counter = seq.to_bytes(8, "big")
return bytes(a ^ b for a, b in zip(write_iv, b"\x00\x00\x00\x00" + counter))
The construction gives uniqueness without transmitting anything. The IV is constant for a given key, each sequence number occurs once per key, and exclusive-or with distinct inputs yields distinct outputs. The requirement that the IV change on key update is what keeps the argument intact across a long connection: a new traffic secret produces a new IV, so the counter may restart at zero.
Nonce reuse is the failure mode that matters. Reusing a GCM nonce under the same key exposes the exclusive-or of two plaintexts and, more seriously, allows recovery of the authentication subkey, after which an attacker can forge records with valid tags. Neither AES-GCM nor ChaCha20-Poly1305 fails gracefully here; both simply break. That is why the nonce is derived rather than transmitted, and why the data limits in the cipher suite specification are enforced rather than advisory.
The associated data is the five-byte header and nothing else. It binds the ciphertext to the record type, the version field, and the length, so an attacker cannot relabel a record or splice one record’s payload into another’s header without invalidating the tag. The plaintext itself is content || type || padding, and the receiver strips trailing zeros until it finds a nonzero byte, which it interprets as the content type. A zero-length application record is therefore legal and carries only padding.
What the framing still reveals
The record layer protects contents and integrity, not metadata. Record lengths are visible, so the size of a response is visible even when its content is not. The number and timing of records reveal the shape of an interaction: a request followed by a burst, a long pause, a slow trickle of small records consistent with typing. Padding can blunt this, within the 2^14 + 1 byte ceiling on the inner plaintext, but padding costs bandwidth and no general-purpose stack applies it by default. The limits of that mitigation are worth understanding before assuming that an encrypted connection hides more than it does.
The other boundary is structural. A record layer cannot reorder, retransmit, or recover from loss, because it has delegated all of that to TCP. It also cannot authenticate the endpoints; that is the handshake’s job, described in the handshake message flow, and the record layer inherits whatever assurance the handshake produced. When a connection is resumed from a ticket, the record layer protects traffic under keys that trace back to an earlier authentication, which is why the resumption path deserves separate scrutiny. The keys themselves come from the key schedule, and the nonce construction here is the only consumer of the IVs it produces.
References
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF
- RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2 — IETF
- RFC 8439: ChaCha20 and Poly1305 for IETF Protocols — IETF
- NIST SP 800-38D: Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC — NIST