Skip to content

QUIC initial packets and the fingerprint surface of the handshake

· 18 min read
Copyright: MIT
Byte layout of a QUIC Initial packet long header with the version and connection ID fields highlighted

The promise of QUIC was that the transport would disappear. TCP’s handshake sits in plaintext, its options and timestamps and window scaling readable by anything on the path. QUIC moved the handshake inside TLS and wrapped the whole thing in AEAD, so the marketing line was that an observer would see UDP and nothing else. That line is mostly true after the connection is up. It is not true for the very first packet.

The QUIC Initial packet has to be readable by a server that holds no shared secret yet, which means its keys are derived from a value printed in the clear on the same packet. The salt is a constant published in an RFC. So the “encryption” on an Initial packet protects against nobody: any observer who can read the bytes can derive the same keys, decrypt the payload, and recover the TLS ClientHello inside. The result is that the single packet meant to bootstrap a private transport is also the richest fingerprinting target QUIC exposes. This post is a walk through that packet, field by field, and an accounting of what each field leaks.

We start with the long header and where the Initial sits among the other long-header types. Then the version field and why a four-byte constant is a stronger client tell than it looks. Connection IDs and their length encoding. The salt-and-HKDF derivation that makes the “encryption” decorative. The padding rule that forces every client Initial datagram to a known minimum, and the coalescing rule that packs several packets into one datagram. Finally, what an on-path observer actually gets, how JA4 and similar schemes turn it into a string, and where the public documentation runs out.

The long header and the Initial’s place in it

QUIC has two header shapes. The short header is what carries application data once the connection is established, and it is deliberately sparse: a few flag bits, a connection ID, an encrypted packet number. The long header is the verbose one, used during setup, and the Initial packet wears it.

The first byte of a long-header packet is a small bitfield. The high bit is the Header Form bit, set to 1 for a long header. The next bit is the Fixed Bit, which RFC 9000 says MUST be set to 1 for a valid packet (its only job is to make QUIC distinguishable from some other UDP protocols and to resist a class of demultiplexing tricks). Then two bits of Long Packet Type, two Reserved bits, and two bits of Packet Number Length. For QUIC version 1 the Initial packet carries type value 0b00. A 0-RTT packet is 0b01, Handshake is 0b10, Retry is 0b11. Those four type codes, plus the Version Negotiation packet which is signalled by an all-zero version field rather than a type code, are the entire long-header family.

First byte of a long-header packet (QUIC v1) 1 1 T T R R P P form fixed type reserved pkt-num len type bits: 00 Initial 01 0-RTT 10 Handshake 11 Retry reserved + pkt-num-length bits are hidden under header protection form=1, fixed=1, type=00 → first byte high nibble is 1100 = 0xC_ *The first byte of a QUIC v1 long header. The two type bits sit in the clear; the reserved and packet-number-length bits are masked by header protection and only appear after you remove the mask.*

A subtlety that matters for fingerprinting: the Reserved and Packet Number Length bits in that first byte are not visible as sent. They sit under header protection, the lightweight masking layer QUIC applies on top of the AEAD. So an observer reading raw bytes sees the form bit, the fixed bit, and the type bits in the clear, but the low four bits of the first byte are XORed with a mask derived from the encrypted payload. To recover them you have to do the same key derivation the endpoints do. For the Initial packet that derivation is open to everyone, which is the whole problem.

A four-byte version that pins the client

After the first byte come four bytes of Version. QUIC version 1, the one standardised in RFC 9000, is 0x00000001. A version of 0x00000000 is special: it marks a Version Negotiation packet, the mechanism a server uses to tell a client none of its offered versions are supported.

Four bytes does not sound like much entropy, and on its own it is not. Almost everything in production is version 1. But the version field becomes interesting once you remember that QUIC has more than one version in real deployment. RFC 9369 defines QUIC version 2 with the wire value 0x6b3343cf, the first four bytes of the SHA-256 of the string “QUICv2 version number”. Version 2 is not a protocol upgrade in any meaningful sense; it changes almost nothing about behaviour. Its entire reason to exist is anti-ossification. The working group wanted at least two versions deployed at scale so that middleboxes and servers could not hardcode “QUIC means version 1” and break the next real version. A client that offers version 2, or that greases the version field with a deliberately unassigned value, tells you something about which stack it runs and how recently it was built.

Version 2 also moves the cryptographic furniture around, which is the part that bites anyone trying to passively decrypt Initials at scale. The initial salt changes from the version 1 constant to 0x0dede3def700a6db819381be6e269dcbf9bd2ed9, the first twenty bytes of the SHA-256 of “QUICv2 salt”. The HKDF labels gain a version tag, so "quic key" becomes "quicv2 key", "quic iv" becomes "quicv2 iv", and "quic hp" becomes "quicv2 hp". The long-header type codes are permuted too: in version 2 the Initial type is 0b01, not 0b00, Retry becomes 0b00, and the rest shift accordingly. An observer that wants to read both versions has to branch on the version field before it can derive a single key. That is the anti-ossification design working exactly as intended, and it is also a small tax on every fingerprinting pipeline that wants to keep up.

Connection IDs and the length byte that frames them

Two connection IDs follow the version, each preceded by a one-byte length. The Destination Connection ID comes first: one length octet, then that many bytes of ID. Then the Source Connection ID, same shape. On a client’s very first Initial, the DCID is a value the client picks at random and the server has never seen. The SCID is the client’s own chosen ID for the server to address it by.

The length byte is an eight-bit unsigned integer, so the field can in principle express 0 to 255. QUIC version 1 caps it: a connection ID MUST NOT exceed 20 bytes, and a packet carrying a length above 20 is treated as having a protocol error (the larger range survives only so that a future version’s Version Negotiation packet can still be parsed by a version 1 endpoint). That cap is itself a fingerprint axis. Browsers and stacks differ in how long a DCID they generate on the first flight. Chromium’s QUIC tends toward 8-byte connection IDs; other implementations pick other lengths. The length is plaintext, sitting right after the version, and it is one of the cheapest discriminators on the packet.

QUIC v1 client Initial packet, in order first byte 0xC? form/fixed/type=Initial/reserved/pktnum-len Version 00 00 00 01 (v1) / 6b 33 43 cf (v2) DCID len Destination Connection ID (0..20 bytes, random on first flight) SCID len Source Connection ID (0..20 bytes, client-chosen) Token len Token (empty on 1st) Length (varint) Pkt Number AEAD-protected payload: CRYPTO frame (TLS ClientHello) + PADDING whole datagram padded to ≥ 1200 bytes everything above the payload box is plaintext to an on-path observer *Field order of a client Initial. Plaintext from the first byte down through the packet-number region; the payload is AEAD-protected but with keys anyone can derive.*

After the SCID comes a Token Length and Token, both varint-prefixed and empty on a client’s first Initial. The token shows up later, after a Retry. When a server wants to validate that the client’s source address is real before committing resources, it answers the first Initial with a Retry packet carrying an opaque token and a fresh connection ID. The client repeats its Initial with that token echoed back. Section 8 of RFC 9000 ties this to the anti-amplification rule: until the server has validated the client’s address, it MUST NOT send more than three times the bytes it has received. The Retry path, the token, and the Original Destination Connection ID baked into the Retry Integrity Tag are all observable, and whether a client even reaches the Retry path depends on the server, but the presence and shape of a token on a second Initial is one more thing on the wire.

Then a Length varint covering the rest of the packet, the protected Packet Number, and the payload.

The salt that makes the encryption decorative

Here is the mechanism that turns the Initial packet from a private thing into a public one. The keys protecting an Initial are not negotiated. They are derived, by a fixed recipe, from the client’s Destination Connection ID and a constant published in RFC 9001.

The recipe is HKDF with SHA-256. The extract step takes the published salt and the client’s DCID as input keying material:

initial_secret = HKDF-Extract(initial_salt, client_dst_connection_id)
client_initial_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
server_initial_secret = HKDF-Expand-Label(initial_secret, "server in", "", 32)

For QUIC version 1 the salt is 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a. The labels "client in" and "server in" separate the two directions. From each direction’s secret, three further HKDF-Expand-Label calls produce the AEAD key ("quic key"), the AEAD nonce material ("quic iv"), and the header-protection key ("quic hp"). Initial packets use AEAD_AES_128_GCM, and the header protection uses 128-bit AES in ECB mode to generate the mask. None of these inputs are secret. The salt is in the RFC. The DCID is in the packet. Anyone who can read the packet can run the same three lines.

Anyone on the path can derive the Initial keys published salt (RFC 9001) 38762cf7…ccbb7f0a client DCID (read off the packet) e.g. 08 00 01 02 03 04 05 06 07 HKDF-Extract → initial_secret "client in" → client secret "server in" → server secret each secret → "quic key" / "quic iv" / "quic hp" (AES-128-GCM + ECB mask) no secret input → confidentiality of the Initial is zero *The Initial-key derivation. Both inputs are public, so the AEAD on an Initial packet authenticates the handshake against tampering but hides nothing from a reader.*

RFC 9001 is blunt about what this buys: Initial packets “are not considered to have confidentiality or integrity protection” against an attacker, because the keys are trivially derivable. The AEAD is there to detect accidental corruption and to bind the packet to its connection ID, not to keep secrets. So the TLS ClientHello sitting inside the Initial’s CRYPTO frame is, for fingerprinting purposes, plaintext. The server name (unless ECH is in use), the cipher list, the supported groups, the ALPN values, the signature algorithms, the full ordered extension list, and the QUIC transport parameters carried as a TLS extension are all recoverable by an observer with a copy of the salt and twenty lines of code.

This is why the QUIC story for fingerprinting looks so much like the TLS ClientHello story. The same ClientHello that JA3 and JA4 chew on over TCP is present here, just wrapped in a CRYPTO frame inside an AEAD whose key is public. If you have read how server-side libraries capture the TLS handshake, the QUIC case is that pipeline with one extra unwrap step at the front.

The padding rule and the coalescing rule

Two framing rules shape the size and structure of what hits the wire, and both leave marks.

The first is padding. A client’s Initial packet must not arrive in a tiny datagram. RFC 9000 requires that any UDP datagram carrying a client Initial be padded to at least 1200 bytes. The reason is amplification defence: a server is allowed to send up to three times what it received before it validates the client’s address, so forcing the client’s first datagram up to 1200 bytes raises the floor under that ratio and limits how much a spoofed-source Initial could make a server emit. The practical consequence on the wire is that essentially every client Initial datagram is at least 1200 bytes, and the padding is carried as explicit PADDING frames (frame type 0x00, a run of zero bytes) inside the AEAD payload after the CRYPTO frame. How a client distributes that padding, whether it pads inside the Initial packet or across coalesced packets, and the exact total it pads to are implementation choices.

The second rule is coalescing. A single UDP datagram may carry more than one QUIC packet, back to back, as long as only the last one omits an explicit length and runs to the end of the datagram. During the handshake a sender will often coalesce packets at increasing encryption levels into one datagram: an Initial, then a Handshake, then a 1-RTT packet, in that order. RFC 9000 recommends coalescing in order of increasing encryption level so the receiver can process the datagram in a single pass, because it gains the keys for each level as it works through the handshake. A receiver that cannot yet decrypt a coalesced packet, because it lacks the keys, must keep processing the others rather than dropping the datagram.

One UDP datagram, ≥ 1200 bytes Initial packet long header · CRYPTO frame (ClientHello) · PADDING frames — or, when the client has more to send, coalesced — Initial CRYPTO + PADDING Handshake 1-RTT *Coalescing packs increasing encryption levels into one datagram, Initial first. Only the last coalesced packet runs to the datagram's end without its own length field.*

For an observer, the upshot is a set of size and ordering signals that survive encryption. The datagram length, the number of coalesced packets, the order they appear in, and where the boundaries fall are all visible from the outside even when the inner payloads are not. Initial-packet length and the coalescing pattern have been used directly as features in flow classifiers that label QUIC traffic by application without ever decrypting it. The padding constant of 1200 bytes is a floor, not a fixed value, so the exact size a given client pads to becomes part of its signature.

What the observer actually gets

Put the fields together and the Initial packet hands a passive observer a layered fingerprint.

At the outer QUIC layer, in the clear: the long-header form and type, the version, both connection ID lengths and their raw bytes, the token (empty or present), and the datagram size and coalescing structure. The version pins the stack family and its recency. The DCID length is a cheap per-implementation discriminator. Connection-ID generation patterns, whether the bytes look uniformly random or carry structure, differ between stacks. RFC 9312, the manageability document, catalogues exactly this: the version, the connection IDs, and the header form are the version-independent properties an on-path observer can rely on, and it warns that pinning on a specific version number to recognise QUIC “is likely to persistently miss a fraction of QUIC flows and completely fail in the near future,” which is the version-2 anti-ossification point seen from the network operator’s chair.

One layer down, after the public key derivation, the AEAD payload yields the CRYPTO frame and with it the full TLS ClientHello. This is where the heavy fingerprinting lives, and it is the same surface that drives JA3-to-JA4 over TCP. The cipher suites, the supported groups, the ALPN list, the signature algorithms, and above all the ordered extension list separate one client build from another. The QUIC transport parameters ride inside the ClientHello as a TLS extension, and they carry their own per-implementation tells: which parameters a client sends, in what order, with what values for things like the active connection ID limit and idle timeout. Those are integrity-protected as part of the handshake but readable from the start.

JA4’s QUIC handling reflects this two-layer reality in a small way. The first character of a JA4 fingerprint is q, d, or t, denoting QUIC, DTLS, or TLS-over-TCP. So a JA4 string carries the fact that the ClientHello arrived over QUIC, then fingerprints the ClientHello contents the same way it would over TCP: a leading section with protocol, TLS version, SNI presence, cipher and extension counts and the first ALPN value, then a hash of the sorted cipher suites, then a hash of the sorted extensions and signature algorithms. The published JA4 spec fingerprints the inner TLS ClientHello; it does not, in its core form, fold the outer QUIC fields like DCID length, the transport-parameter ordering, or the coalescing shape into the hash. That gap is acknowledged in the wild, the BrowserLeaks QUIC test notes that the current JA4 format “does not cover all useful signals, such as QUIC Transport Parameters and other QUIC connection artifacts.” The transport-layer signal is real and partly orthogonal to the TLS-layer one, and capturing it fully is left to whatever pipeline is doing the capture.

If you want to see how thin the line is between “this is a transport detail” and “this is an evasion target,” look at the tooling. The refraction-networking group maintains uquic, a hard fork of quic-go whose stated purpose is low-level control of the Initial packet so a client can be made to look like a different client. It exposes the QUIC header fields, the frames, the ClientHello via uTLS, and the transport parameters, and it ships preset profiles meant to reproduce the Initial-packet signatures of Chrome, Firefox, Safari, and Edge. The maintainers are honest that the mimicry “MAY NOT be realistically indistinguishable” from the real clients. That hedge is the whole story of transport fingerprinting in one line: the surface is large enough that reproducing one or two visible fields is easy and reproducing all of them, in the exact combination a real browser emits, is hard. The same arms race plays out one layer up in the browser ClientHello catalog and in why a plain Python HTTP client is fingerprintable before it sends a byte.

Where the public record stops

Most of what is above comes straight from RFCs 9000, 9001, 9312, and 9369, and from byte-level walkthroughs of real handshakes. That covers the structure of the Initial and the cryptography that makes it readable. What the public record does not give you is the exact, current, per-vendor table of Initial-packet signatures: which DCID length each shipping browser build uses this quarter, the precise transport-parameter ordering for each, the padding totals, and how often those drift. Those are observable by anyone who captures traffic, but they are not published as a stable reference, and they change as browsers update. Treat any specific “Chrome uses N-byte connection IDs” claim, including the ones in this post, as a snapshot of observed behaviour rather than a spec guarantee. The spec fixes the field layout; the values inside the fields are an implementation’s to choose, and they move.

What does not move is the structural fact under all of it. QUIC encrypted the transport everywhere except the one packet that has to be readable before any secret exists, and it chose to make that packet’s keys a published function of a value the packet itself carries. That was a deliberate trade. It let servers process a cold Initial with no per-connection state and let the protocol drop the plaintext negotiation TCP-plus-TLS used to expose. The cost is that the first packet of a “private” transport is a high-resolution identity document, readable by anyone on the path, and the only thing standing between an observer and the ClientHello inside it is a constant they can copy out of an RFC. The handshake that was supposed to vanish is the handshake that tells you the most.


Sources & further reading

Further reading