TLS Record Protocol
Framing, AEAD encryption, sequence numbers, and key derivation per record
Once the TLS handshake completes, all application data travels through the Record Protocol. The record layer is responsible for fragmenting data into records (up to 214 = 16,384 bytes each), encrypting each record under the negotiated session keys, and verifying the integrity of received records. Everything the application sends — HTTP requests, WebSocket frames, gRPC messages — passes through this layer before it hits the wire.
TLS 1.2 and TLS 1.3 use fundamentally different approaches: TLS 1.2 uses CBC block ciphers with HMAC or AES-CBC with GMAC (for GCM suites), while TLS 1.3 uses AEAD ciphers exclusively (AES-GCM, ChaCha20-Poly1305). The TLS 1.3 design eliminates entire attack classes — no more MAC-then-encrypt bugs, no more Lucky13 timing attacks on CBC processing, no more POODLE on padding.
Hex Dump Annotator
Each TLS record begins with a 5-byte header. Paste or inspect a hex dump to see each field annotated. The first byte is the content type (23 = application_data in TLS 1.3), followed by version, length, and the encrypted payload.
Encryption / Decryption Flow
Walk through how a plaintext TLS record becomes ciphertext and back. Each step is annotated; hover or click the steps to highlight them.
nonce = seq_num[0..11] XOR write_iv β each record has a unique nonce without a random IV
TLS 1.2 Record Layer
TLS 1.2 uses a composition of separate primitives: a stream or block cipher for encryption, plus a separate HMAC for integrity. The order of operations (MAC-then-encrypt vs encrypt-then-MAC) matters enormously for security.
TLS 1.3 AEAD Record Protocol
TLS 1.3 uses only AEAD ciphers. AEAD means authentication and encryption are combined into a single primitive β there's no way to separate them, no padding to worry about, and no MAC-then-encrypt vs encrypt-then-MAC debate.
{`# AEAD encryption in TLS 1.3 (RFC 8446 Β§5.2)
#
# Each record is encrypted as:
# nonce = XOR(sequence_number, write_iv)
# ciphertext = AEAD-Encrypt(write_key, nonce, aad, plaintext)
# auth_tag = last 16 bytes of AEAD output
#
# AAD (Additional Authenticated Data) = TLS record header:
# stream_protocol_version || content_type || 0..0 padding to length 13
# i.e., the first 13 bytes of the TLS 1.3 record header (before length)
#
# This binds the ciphertext to the record metadata, preventing
# an attacker from stripping the record and re-appending it to another connection.
# For AES-GCM (TLS_AES_128_GCM_SHA256):
# key = 16 bytes (128-bit AES key)
# nonce = 12 bytes (sequence_number XOR write_iv)
# AAD = 13 bytes (TLS record header)
# tag = 16 bytes (GMAC output)
# overhead per record = 16 bytes (authentication tag)
# For ChaCha20-Poly1305 (TLS_CHACHA20_POLY1305_SHA256):
# key = 32 bytes
# nonce = 12 bytes (same XOR construction)
# AAD = 13 bytes (same record header)
# tag = 16 bytes (Poly1305 output)
# overhead per record = 16 bytes
# For the receiver:
# nonce = XOR(decrypted_sequence_number, read_iv)
# plaintext = AEAD-Decrypt(read_key, nonce, aad, ciphertext || auth_tag)
# If decryption fails β fatal alert: bad_record_mac
# This is an ALL-OR-NOTHING check: no partial plaintext leaks timing info.
# QUIC (RFC 9000) replaces the TLS record layer entirely.
# QUIC data is encrypted frame-by-frame with per-frame AAD,
# where the AAD is the QUIC packet header (not the TLS record header).
# This gives QUIC additional metadata protection that TLS 1.3 doesn't have.`} Sequence Numbers and Replay Prevention
Every TLS record carries a 64-bit sequence number incremented per record. The sequence number is used to construct the per-record nonce for AEAD ciphers, and a separate counter per direction (read vs write) ensures records from each direction are independent.
{`# TLS sequence number: 64-bit unsigned integer
# Per connection, per direction:
# client_write_sequence_number (incremented per sent record)
# server_write_sequence_number (incremented per sent record)
# client_read_sequence_number (incremented per received record)
# server_read_sequence_number (incremented per received record)
#
# The sequence number is NOT sent on the wire as plaintext β it's implicit.
# The receiver knows which record it's receiving based on arrival order.
# The 64-bit counter overflow wraps at 2^64-1.
# Why sequence numbers matter:
# 1. Nonce uniqueness for AEAD ciphers:
# nonce = seq_num XOR write_iv
# If the same (key, nonce) pair is ever reused, GCM breaks catastrophically.
# (GCM mode with nonce reuse: keystream leaks, authentication breaks)
#
# 2. Replay detection (0-RTT in TLS 1.3):
# The server maintains a anti-replay window of accepted 0-RTT records.
# Records with sequence numbers outside the window are rejected.
# A record with a duplicate sequence number (inside the window) is dropped.
# This prevents an attacker from replaying previously recorded 0-RTT data.
#
# 3. Connection drying:
# After a KeyUpdate, both sides increment sequence numbers from 0
# for the new epoch. The AEAD key is bound to the epoch number.
# Python: demonstrate AEAD nonce construction
python3 << 'EOF'
import struct
seq_num = 0x0000000000000041 # 65th record
write_iv = bytes.fromhex('00112233445566778899aabbcc') # 12 bytes
nonce = bytes(a ^ b for a, b in zip(
struct.pack('!Q', seq_num).rjust(12, b'\x00'), # seq num as 12 bytes
write_iv
))
print("Seq num (hex):", hex(seq_num))
print("Write IV: ", write_iv.hex())
print("Nonce: ", nonce.hex())
# Nonce = write_iv XOR seq_num (with leading zeros)
EOF`} Key Derivation: Master Secret and Key Expansion
TLS 1.2 derives a 48-byte master_secret from the pre-master
secret (from the key exchange), then expands it into write keys, IVs, and
MAC keys for each direction. TLS 1.3 replaced this with the HKDF key schedule.
{`# TLS 1.2 key derivation (RFC 5246 Β§6.3)
#
# master_secret = PRF(pre_master_secret, "master secret", client_random || server_random)
#
# Key block (extracted from master_secret):
# client_write_MAC_key[0..hash_length-1]
# server_write_MAC_key[0..hash_length-1]
# client_write_key[0..key_length-1]
# server_write_key[0..key_length-1]
# client_write_IV[0..IV_length-1]
# server_write_IV[0..IV_length-1]
#
# key_block = PRF(master_secret, "key expansion", server_random || client_random)
# Keys are sliced from key_block sequentially.
# For AES-128-GCM: 16-byte write_key + 4-byte salt (IV partial) = 20 bytes per direction.
# For ChaCha20: 32-byte key.
# TLS 1.3 HKDF key schedule (RFC 8446 Β§7.1)
# HKDF-Extract(ZERO, ECDHE_shared_secret) = handshake_secret
# HKDF-Expand-Label derives per-secret traffic secrets.
#
# Each traffic secret is expanded into key + nonce:
# client_write_key = HKDF-Expand-Label(
# client_application_traffic_secret_0,
# "key", "", 16) # 16-byte AES key
# client_write_iv = HKDF-Expand-Label(
# client_application_traffic_secret_0,
# "iv", "", 12) # 12-byte IV
#
# KeyUpdate is an explicit message that rotates to a new traffic secret
# without a new handshake β much simpler than renegotiation in TLS 1.2.
# Verify key derivation with OpenSSL
openssl s_client -connect example.com:443 -tls1_3 &1 \
| grep -E "SSL-Session|key material"
# Or dump with traffic secret logging (requires debug build):
# OPENSSL_ENABLE_DEBUG_TLS=1 openssl s_server ...`} TLS 1.2 CBC Mode Details
For CBC-based TLS 1.2 cipher suites (now discouraged), the block cipher processes 16-byte blocks with padding appended. This padding is the source of the POODLE attack — an attacker who can inject a record into a connection can exploit the padding oracle to recover plaintext.
{`# AES-CBC in TLS 1.2:
# Block size: 16 bytes. Padding: 1..16 bytes appended.
# Padded plaintext: [data][MAC][padding_length_bytes]
# If data+MAC is not a multiple of 16: pad.
# padding_length byte = number of padding bytes (including itself).
# Example: if 9 bytes needed to fill block, padding_length = 0x09.
#
# Encryption:
# IV = client_write_IV (first record: random; subsequent: prev_ciphertext)
# Block 0: CIPH(IV XOR plaintext[0:16])
# Block 1: CIPH(ciphertext[0:16] XOR plaintext[16:32])
# ...
# ciphertext = IV || cblock0 || cblock1 || ...
#
# Decryption (vulnerable to padding oracle):
# Decrypt all blocks
# Check last byte of last block: value = padding_length
# Remove padding_length bytes
# Strip MAC bytes, verify HMAC
#
# POODLE (CVE-2014-3566): TLS uses block cipher padding in a way that
# some servers accept a padding_length byte that is inside the MAC,
# allowing an attacker to guess one byte of plaintext per attempt.
# Mitigation: disable all CBC cipher suites (use GCM/ChaCha20).
# Lucky13 (CVE-2013-0169): timing side-channel in CBC processing.
# An attacker measures response time differences when MAC verification
# fails at different stages. Fix: constant-time MAC verification.
# TLS 1.3 eliminates CBC modes entirely β no Lucky13.
# Check which cipher modes your server offers:
openssl s_client -connect example.com:443 -tls1_2 -cipher 'AES128' 2>&1 \
| grep -i "cipher\|error"
# If any AES-CBC cipher is negotiated, consider disabling TLS 1.2 or
# using a cipher string like: ECDHE+AESGCM:ECDHE+CHACHA20`} Record Types and Content Types
TLS carries multiple record types over the same connection after the handshake, distinguished by the content type in the record header. TLS 1.3 encrypts all types post-handshake, making traffic analysis harder.
| Content Type | Value | Description | Encrypted in TLS 1.3? |
|---|---|---|---|
| change_cipher_spec | 20 (0x14) | Signals cipher suite switch after Finished | No (TLS 1.2 only) |
| alert | 21 (0x15) | Warning/fatal alerts (close_notify, illegal_parameter) | Yes |
| handshake | 22 (0x16) | ClientHello, ServerHello, certificates, Finished, etc. | Partially (post-ServerHello encrypted) |
| application_data | 23 (0x17) | Encrypted HTTP/2, gRPC, WebSocket, etc. | Yes |
| heartbeat | 24 (0x18) | DTLS heartbeat extension (not TLS 1.3) | Yes |
{`# In TLS 1.3, the content type is wrapped in a "outer" record:
# outer: content_type = 23 (application_data)
# inner: content_type = payload type inside the encrypted payload
#
# This means the content type visible to the network is always 23
# for application_data records β traffic analysis becomes harder.
# In TLS 1.2, an observer could see that record type 22 (handshake)
# was still being exchanged after ClientHello, leaking metadata.
# Heartbleed (CVE-2014-0160):
# The heartbeat extension (RFC 6520) lets a client send:
# heartbeat_request: payload || padding
# Server responds with: payload (echoed back) || padding
# No bounds check: server reads beyond payload length into adjacent memory.
# Fix: always bounds-check reads; OpenSSL 1.0.1g adds the check.
# Detection: check for heartbeat in server hello:
openssl s_client -connect example.com:443 -tls1_2 &1 \
| grep -i "heartbeat"
# Alert levels (content type 21 = alert)
# 1 = warning (e.g., close_notify, no_certificate)
# 2 = fatal (e.g., bad_record_mac, handshake_failure, internal_error)
# Alert descriptions: ~30 defined alert types.
# All alerts are encrypted in TLS 1.3 (even close_notify was visible in TLS 1.2).`} Tradeoffs
Record size vs. crypto overhead
Each TLS record carries a 5-byte header and (for AEAD) a 16-byte auth tag. A 1-byte HTTP/2 frame inside a 16KB TLS record wastes little overhead. Small records (e.g., streaming video chunking into 1KB records) pay more overhead per record. The sweet spot balances latency (flush boundaries) against crypto efficiency.
AAD and traffic analysis resistance
AEAD's AAD binds the ciphertext to the record header, preventing ciphertext stripping attacks. But the network still sees record boundaries and approximate sizes. Application-level padding (HTTP/2's padding frame, TLS 1.3's plaintext padding) is needed to defeat traffic analysis. TLS 1.3's 0-RTT records are distinguishable from 1-RTT records by their position in the connection.
Sequence number overflow and anti-replay
A 64-bit sequence number sounds large, but high-throughput servers sending 1 million records/second exhaust it in ~292,000 years β no practical concern. For 0-RTT replay though, the anti-replay window is typically a few minutes, and servers must remember which sequence numbers they've accepted. This state is the main DoS vector for 0-RTT.
KeyUpdate vs. session resumption
TLS 1.3's KeyUpdate message rotates traffic keys without a new handshake β useful after long sessions where the key lifetime approaches exhaustion. The alternative (renegotiation in TLS 1.2, session tickets in TLS 1.3) requires additional round trips. KeyUpdate is a one-message operation on top of an existing connection.
FAQ
Why does each TLS record carry a sequence number if it's not sent on the wire?
The sender and receiver maintain independent counters that increment for each sent/received record. The sender's counter becomes part of the AEAD nonce; the receiver knows which record it's processing by the order of arrival. The 64-bit length means overflow is never a concern in practice (2^64 records = ~18 quintillion). The counter is implicit, not transmitted β which also means a man-in-the-middle can't influence it.
What is the "inner content type" in TLS 1.3?
TLS 1.3 encrypts the content type inside the encrypted payload, after the outer record header. So the outer header always says application_data (0x17) even when carrying a handshake message or alert. The inner content type (within the decrypted payload) tells the TLS stack what message type it is. This prevents network observers from seeing which TLS messages are being exchanged after the handshake.
What is the maximum TLS record size?
Each TLS record carries a 16-bit length field, allowing up to 214 = 16,384 bytes of plaintext, plus padding for block ciphers. TCP typically fragments this into MTU-sized chunks. TLS 1.3 adds a max_early_data_size field in session tickets to limit how much 0-RTT data can be sent. In practice, HTTP/2 multiplexing means large files still get chunked into records sized to the congestion window.
How does DTLS differ from TLS in the record layer?
DTLS (Datagram TLS) adapts TLS for unreliable transports. It adds an explicit 12-byte sequence number in each record (TLS relies on implicit ordering over TCP), plus an epoch field to support KeyUpdate without sequence number resets. DTLS also adds a rehandshake timer for lost handshake messages. The AEAD nonce construction differs: nonce = epoch || sequence_number || 0..0 padded to 12 bytes.
Can attackers see record boundaries in TLS 1.3?
Partially. The TLS record header (5 bytes) is not encrypted in TLS 1.3 β only the content type inside the payload is encrypted. An observer sees record lengths, which can leak information about application data sizes (e.g., 100-byte HTTP responses vs. 10KB downloads). HTTP/2's frame-level padding and TLS 1.3's optional record padding mitigate this, but in practice most deployments don't use padding.
Why is AEAD required in TLS 1.3?
Authenticated encryption (AEAD) combines confidentiality and integrity into one primitive with a single key and a single algorithm. This eliminates the design space where implementations could get the MAC-then-encrypt order wrong, leading to padding oracle attacks (POODLE, Lucky13, etc.). AEAD ciphers produce an authentication tag as part of encryption, making it impossible to verify the MAC on corrupted ciphertext without decrypting first.