On TCP, “TLS fingerprinting” mostly means the ClientHello. That is what JA3 hashed and what uTLS was built to imitate. The transport underneath belongs to the kernel, and every application on the same OS shares it.
QUIC moves the transport into user space. Every QUIC implementation brings its own packetization, its own loss recovery and its own defaults, and all of it runs before the first byte of application data. An observer who can decrypt the Initial packets (see the previous post) gets far more than a ClientHello.
Here are the layers, roughly in the order an observer meets them.
1. The first datagram
Before decrypting anything:
- Size. Clients must pad datagrams carrying Initial packets to at least 1200 bytes. Stacks choose their own target above that floor, and some size datagrams based on their assumed path MTU.
- Long header fields. The version, the DCID length and the SCID length are all in cleartext. The DCID must be at least 8 bytes. The SCID can be anything from 0 to 20. A client that always uses an unusual length is easy to spot.
- Token. A non-empty token in the first Initial means the client is reusing a token from a NEW_TOKEN frame or a Retry, which says something about connection history.
- Coalescing. Whether other packets share the datagram, and whether bytes trail after the last packet.
2. Frame layout inside the Initial
After decryption:
- How many CRYPTO frames the ClientHello is split into, at which offsets, and in what order.
- Whether PING or PADDING frames sit between them, and where the padding goes: in frames, after the packet, or both.
- The packet number length, and the first packet number. Starting at 0 is common but not required.
Chromium randomizes CRYPTO frame splitting on purpose, so its first flight differs from one connection to the next. For someone building a client, that can feel counterintuitive: a static frame layout is actually easier to fingerprint than one that varies.
3. The ClientHello
This layer works the same way as on TCP:
- cipher suites and their order;
- supported groups and key shares (a hybrid post-quantum share is now normal for current browsers);
- extension set and extension order. Chrome has shuffled extension order since
early 2023, so for Chrome the order is noise, but the set still is not.
Firefox does the same over QUIC, but not over TCP: in my tests, a current
Firefox build kept a fixed order in every TCP ClientHello, while every QUIC
ClientHello was permuted, with only
quic_transport_parametersand the ECH extension pinned at the end; - GREASE values (RFC 8701) and where they appear;
- ALPN (
h3for browsers); - signature algorithms, certificate compression algorithms,
record_size_limit; - ECH, whether real or GREASE, and its size.
4. QUIC transport parameters
The quic_transport_parameters extension is a fingerprint of its own:
- Which parameters are present. Optional ones such as
max_datagram_frame_size,grease_quic_bit,version_informationand vendor-specific IDs vary by stack. For example, a client that advertises datagram support while claiming to be Safari is essentially introducing itself to the censor in bold font. - Order. It is not specified, so it simply follows each implementation’s serialization code.
- Values. Flow control windows (
initial_max_data,initial_max_stream_data_*), stream limits,max_idle_timeout,max_udp_payload_size,active_connection_id_limit,ack_delay_exponent. Defaults differ between implementations, and copying them across is rarely done carefully. - GREASE parameters. IDs of the form
31 * N + 27are reserved for GREASE (RFC 9000 §18.1). Whether a stack sends one, how long it is and where it sits are all signals.
5. Behavior over time
This is the layer that static mimicry tends to miss:
- Retransmission. When the first flight is lost, how long the client waits before the probe timeout fires, and whether it resends the same CRYPTO frames, re-splits them or adds PINGs.
- ACKs. ACK frequency and delays, and whether the client acknowledges every ack-eliciting packet during the handshake.
- Handshake timing. How fast the client sends its Handshake packets after receiving the server’s flight, and whether it coalesces them with 1-RTT data.
- Connection IDs. How many connection IDs the client issues with NEW_CONNECTION_ID, and whether it migrates or rotates them.
- Resumption. Whether the client ever resumes a session, and whether it ever sends 0-RTT. A “browser” that does a full handshake every time, for months, toward the same server is unusual.
- Shape after the handshake. Stream versus datagram usage, packet size distributions, and the idle/keepalive pattern.
Consistency is the real requirement
Imitating any one layer is not hard. The hard part is that every layer has to tell the same story:
- a Firefox-like ClientHello with a Go-default transport parameter set;
- a Chrome-like extension list in a frame layout that never varies;
- browser-like headers and timeouts, but keepalive PINGs on a fixed interval that no browser uses.
Each of those mismatches is a feature that a classifier can learn, and it only needs one. Some practical consequences:
- Pick one reference client and version, and capture it yourself. Diff your first flight against it layer by layer, not just the ClientHello hash.
- Recheck when the reference updates. Browsers change their ClientHello and their QUIC defaults on their own release schedule, and a frozen copy of last year’s browser becomes a fingerprint of its own.
- Don’t add features the reference doesn’t have. Every capability you advertise that the reference lacks is a mismatch. That includes datagram support, unusual ALPNs and extra transport parameters.
- Fingerprint the server too. The server’s flight (certificate chain size, transport parameters, the NewSessionTicket count, ALPN selection) should match what a real web server returns, and the same consistency rule applies there.
None of this makes a proxy undetectable. Traffic analysis after the handshake is a separate and harder problem. But it does move a client from “identifiable from the first packet” to “requires effort”, which for a censor operating at national scale is a meaningful difference.