QUIC is often described as “encrypted transport”. That is true for almost every packet in a connection, but not the first one. The client’s Initial packets are protected with keys that anyone can derive, and a middlebox that bothers to do so sees the full TLS ClientHello: SNI, ALPN, cipher suites, extension order, transport parameters, everything.
This is not a bug. It is spelled out in RFC 9001, Section 5.2. Initial protection exists to stop off-path attackers from injecting packets and to mitigate protocol ossification, not to hide anything from an on-path observer. Censors have noticed. Since April 2024, the Great Firewall has been decrypting QUIC Initials at scale and blocking connections based on the SNI inside them, as documented by Zohaib et al. at USENIX Security 2025.
This post derives the keys by hand, removes header protection, decrypts a packet, and then looks at what a decrypted Initial actually tells an observer.
Step 1: the secret comes from the packet itself
Every QUIC version defines a fixed salt. For QUIC v1 it is:
38762cf7f55934b34d179ae6a4c80cadccbb7f0a
The input keying material is the Destination Connection ID of the client’s first Initial packet, which sits in cleartext in the long header. That gives:
initial_secret = HKDF-Extract(salt, client_dst_connection_id)
client_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
server_secret = HKDF-Expand-Label(initial_secret, "server in", "", 32)
HKDF-Expand-Label is the TLS 1.3 construction. The label is prefixed with
"tls13 " and encoded together with the output length and an empty context.
From each direction’s secret, three more labels give the AEAD key, the IV and
the header protection key:
key = HKDF-Expand-Label(secret, "quic key", "", 16)
iv = HKDF-Expand-Label(secret, "quic iv", "", 12)
hp = HKDF-Expand-Label(secret, "quic hp", "", 16)
Initial packets always use AES-128-GCM, whatever cipher suite the handshake later negotiates.
Here it is in Python, using the cryptography package:
import struct
from cryptography.hazmat.primitives import hashes, hmac
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
SALT_V1 = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
def expand_label(secret: bytes, label: bytes, length: int) -> bytes:
full = b"tls13 " + label
info = struct.pack(">HB", length, len(full)) + full + b"\x00"
return HKDFExpand(hashes.SHA256(), length, info).derive(secret)
def client_initial_keys(dcid: bytes):
h = hmac.HMAC(SALT_V1, hashes.SHA256()) # HKDF-Extract is just HMAC
h.update(dcid)
initial = h.finalize()
client = expand_label(initial, b"client in", 32)
return (expand_label(client, b"quic key", 16),
expand_label(client, b"quic iv", 12),
expand_label(client, b"quic hp", 16))
RFC 9001 Appendix A uses the DCID 8394c8f03e515708. Running the function on
it reproduces the published values:
client_initial_secret c00cf151ca5be075ed0ebfb5c80323c42d6b7db67881289af4008f1f6c357aea
key 1f369613dd76d5467730efcbe3b1a22d
iv fa044b2f42a3fd3b46fb255c
hp 9f50449e04a0e810283a1e9933adedd2
If your numbers match these, your key derivation is correct.
Step 2: remove header protection
The first byte’s low bits and the packet number are masked. To unmask them:
- Parse the long header up to the Length field (first byte + version + DCID
length + DCID + SCID length + SCID + token length + token + Length). That
gives
pn_offset, the start of the packet number. - Take a 16-byte sample starting at
pn_offset + 4. The sample assumes a 4-byte packet number, so it never overlaps the real one. mask = AES-ECB(hp, sample).- For a long header,
first_byte ^= mask[0] & 0x0f. The low two bits of the result give the packet number length, minus one. - XOR the packet number bytes with
mask[1:1+pn_len].
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def unmask(packet: bytes, pn_offset: int, hp: bytes):
sample = packet[pn_offset + 4 : pn_offset + 20]
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(sample)
first = packet[0] ^ (mask[0] & 0x0F)
pn_len = (first & 0x03) + 1
pn_bytes = bytes(packet[pn_offset + i] ^ mask[1 + i] for i in range(pn_len))
header = bytes([first]) + packet[1:pn_offset] + pn_bytes
return header, int.from_bytes(pn_bytes, "big"), pn_len
With the Appendix A sample d1b1c98dd7689fb8ec11d242b123dc9b, the mask
starts with 437b9aec36. The protected first byte c0 becomes c3, so the
packet number is 4 bytes long. The masked 7b9aec34 becomes 00000002.
Step 3: decrypt the payload
The nonce is the IV XORed with the packet number, which is left-padded to 12 bytes. The associated data is the unprotected header, ending with the packet number.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt(packet, pn_offset, length, key, iv, hp):
header, pn, pn_len = unmask(packet, pn_offset, hp)
nonce = bytes(a ^ b for a, b in zip(iv, pn.to_bytes(12, "big")))
end = pn_offset + length # Length covers PN + payload + tag
return AESGCM(key).decrypt(nonce, packet[pn_offset + pn_len : end], header)
Note end. A UDP datagram can carry more than the Initial: coalesced
Handshake or 0-RTT packets, or plain trailing bytes. Whatever follows the
Initial’s Length is not part of the Initial.
Two details trip people up:
- Keys come from the first DCID. Once the server replies, the client switches its Destination Connection ID to whatever the server chose. Initial packets sent after that still use keys from the original DCID. If the server sent a Retry, the client derives new Initial keys from the new Destination Connection ID provided in the Retry packet (its Source Connection ID), and nothing else.
- The ClientHello may span packets. With post-quantum hybrid key shares such as X25519MLKEM768, a ClientHello no longer fits in one 1200-byte datagram. CRYPTO frames carry an offset, so reassemble by offset rather than by arrival order.
What the plaintext gives away
An Initial payload is a sequence of frames. In a client’s first flight you
will typically see only three kinds: CRYPTO (0x06), PADDING (0x00) and
PING (0x01). Even before parsing TLS, the layout says a lot:
- Datagram size. RFC 9000 requires clients to pad datagrams that carry Initials to at least 1200 bytes. Different stacks pick different targets above that floor.
- Where the padding goes. Padding can be PADDING frames inside the encrypted payload, or bytes after the packet in the same datagram. Both are legal, and different stacks choose differently.
- How the ClientHello is cut. Some stacks send one CRYPTO frame per packet. Chromium deliberately splits the ClientHello into several CRYPTO frames, sends them out of order, and puts PING and PADDING frames between them. That makes naive middleboxes that only read the first frame fail.
- Connection ID lengths. The client picks both the DCID length (at least 8 bytes) and the SCID length (which can be zero). Implementations have defaults.
Then the ClientHello itself: cipher suites, supported groups, key shares, ALPN,
extension order, GREASE values, and the quic_transport_parameters
extension, with its own set, order and values. Those are the same signals as
TLS-over-TCP fingerprinting, plus a set of QUIC-specific ones. JA4 includes a
protocol marker for exactly this reason: a QUIC ClientHello is not
interchangeable with a TCP one.
Takeaways
- Treat the client’s Initial as plaintext. Everything in it is visible to anyone on the path, and anyone on the path can also be fingerprinting you.
- ECH is the only standard mechanism that hides the inner ClientHello, and it only helps if the server side supports it and the outer ClientHello looks ordinary.
- For an analyst, decrypting Initials takes about forty lines of Python. That is a good reason to look at your own client’s first flight before someone else does.