Skip to content

HTTP/3 and QUIC fingerprinting: the transport parameters that identify a client

· 20 min read
Copyright: MIT
QUIC Initial packet broken into labelled byte fields with the transport-parameters block highlighted

A QUIC connection starts with a single packet that the client sends in cleartext. Inside it is a TLS 1.3 ClientHello, and folded into that ClientHello is a block of transport parameters: flow-control windows, stream limits, idle timeouts, a handful of numbers that the client picked when it was compiled. None of that is encrypted yet. The keys that protect QUIC’s Initial packets are derived from a published salt and the connection ID, so anyone on the path, or the server itself, can read every field. The question this raises is uncomfortable for anyone who thought HTTP/3 was a privacy win: if the first packet carries a fixed set of implementation-chosen numbers, how many of them does it take to tell Chrome from Firefox from a Go client wearing a Chrome costume?

The answer, from the research that has actually measured it, is: not many. A 2022 study at UC San Diego captured QUIC handshakes from Chrome, Firefox, and Opera, hashed the transport parameter values, and got a stable per-browser fingerprint on the first packet. Chrome and Opera collapsed to one hash because they share the Chromium QUIC stack. Firefox produced a different one. The fields were consistent across websites and across machines, and they even drifted between browser versions, which means the same hash that separates vendors can sometimes separate releases. That is the fingerprint surface this post is about.

What follows walks the packet from the outside in. First the Initial packet and why its protection does not hide anything that matters for identity. Then the transport parameters themselves, field by field, with the measured browser values. Then the TLS ClientHello carried inside, where QUIC inherits the entire JA3/JA4 surface for free. Then version negotiation and the GREASE machinery that is supposed to keep all of this from ossifying. Then the HTTP/3 layer on top, with its own SETTINGS frame and pseudo-header order. And finally why detection vendors have not fully turned this on yet, and what changes when they do.

The Initial packet is readable by design

QUIC runs over UDP, and it puts its handshake in the first datagram. RFC 9000 requires a client’s Initial packet, or its coalesced set of Initial packets, to be padded to at least 1,200 bytes so that the path is known to carry a datagram that large before the handshake commits to it. That padding is the first tell. A client that pads exactly to 1,200 and a client that pads to 1,357 are distinguishable, and the choice is an implementation detail nobody standardised.

The Initial packet is encrypted, but the word “encrypted” is doing almost no work here. Initial-packet protection uses keys derived from a fixed salt defined in the RFC and the client’s Destination Connection ID, both of which are visible on the wire. The point of that protection was never confidentiality; it was to make middleboxes do real work if they wanted to mangle the handshake, and to give the packet a consistent shape. For an observer who can run the same key-derivation the spec publishes, the CRYPTO frame inside the Initial packet decrypts trivially. That CRYPTO frame carries the TLS 1.3 ClientHello, and the ClientHello carries the transport parameters. So the chain of custody from “first UDP datagram” to “a hash that names your client” has no real cryptographic break in it.

UDP datagram, padded to ≥ 1200 bytes QUIC long header — version, DCID, SCID, token, length, packet number CRYPTO frame (Initial keys derive from a published salt + DCID) TLS 1.3 ClientHello — ciphers, extensions, ALPN, supported_groups quic_transport_parameters extension (0x39) initial_max_data, initial_max_stream_data_*, max_idle_timeout, … Everything above the orange box is observable without the connection's negotiated keys. *The nesting that makes QUIC fingerprintable on packet one. The transport parameters (orange) sit inside the ClientHello, inside the CRYPTO frame, inside a packet whose protection keys are derived from public values.*

This is the same lesson the older transports already taught, one layer down. A plain TLS ClientHello is fingerprintable before a byte of HTTP moves, which is the whole premise behind server-side TLS fingerprinting and behind tools like uTLS that rewrite the ClientHello to match a real browser. QUIC does not escape that. It wraps it in UDP and adds a second, transport-layer set of identifiers on top.

The transport parameters, field by field

Transport parameters are how each QUIC endpoint tells the other what it is willing to do: how much data it will buffer, how many streams it will allow, how long it will sit idle before tearing the connection down. RFC 9000 defines them in Section 18 as a sequence of (identifier, length, value) triples, each encoded with QUIC’s variable-length integer scheme. Order is not significant, and a duplicate identifier is a connection error. The client’s set rides in the quic_transport_parameters TLS extension (codepoint 0x39) inside the ClientHello.

The spec lists the parameters a version-1 endpoint may send. The ones that carry fingerprint signal are the numeric limits, because nothing in the protocol constrains their values. The RFC gives defaults for a few and ranges for others, but a client is free to pick whatever it wants inside the legal bounds, and every client picks differently.

Client-sent transport parameters that vary by implementation initial_max_data connection-level flow control window initial_max_stream_data_bidi_local per-stream window, locally opened initial_max_stream_data_bidi_remote per-stream window, peer opened initial_max_stream_data_uni per-stream window, unidirectional initial_max_streams_bidi concurrent bidi stream limit initial_max_streams_uni concurrent uni stream limit max_idle_timeout idle teardown, in milliseconds max_udp_payload_size largest datagram the endpoint accepts max_datagram_frame_size DATAGRAM support (RFC 9221) active_connection_id_limit how many CIDs it will track at once Presence, absence, and exact value of each are all signal. Order is not (RFC 9000 §18). *The numeric transport parameters a client sends. The RFC constrains legal ranges, not specific values, so each implementation's choices form a stable signature.*

Here is where the measured data matters more than any prose description, so it is worth being precise about the numbers and where they come from. The UC San Diego study captured these values from real browsers and published them. Chrome and Opera, both on the Chromium QUIC stack, sent initial_max_data of 15,728,640 and initial_max_stream_data_bidi_remote of 6,291,456. Firefox sent initial_max_data of 25,165,824 and initial_max_stream_data_bidi_remote of 1,048,576. Their initial_max_streams_uni differed too: 103 for Chromium against 16 for Firefox. Chromium omitted active_connection_id_limit entirely in that capture while Firefox sent 8, and max_datagram_frame_size was 65,536 for Chromium against 0 for Firefox. Hash that set and Chrome and Opera land on one value, 86cc808155f0f6cf4a5694246e7d5832; Firefox lands on another, 2c35b99f97e416f627765886238c560f. Same first packet, different identity.

Two things in those numbers deserve a second look. The 6291456 window is 6 MiB, which is exactly the same value Chrome uses for its HTTP/2 stream flow-control window. The QUIC stack inherited the constant. That kind of cross-protocol echo is a gift to a detector, because a client claiming to be Chrome over HTTP/3 had better also produce Chrome’s HTTP/2 SETTINGS and window values when it speaks HTTP/2, and a mismatch between the two is far harder to fake than either one alone. The second thing is that these are 2022 values. Browser QUIC stacks change. The point is not the literal constant but the method: capture, hash, compare. A current detector runs the same routine against today’s builds and gets today’s constants.

The study also found the parameters drift across browser releases, not just across vendors. Firefox 90 and 92 carried different initial_max_stream_data_bidi_local and initial_max_data values than the releases that followed, which split the version line into a “before” hash and an “after” hash. So the transport-parameter fingerprint is not only “which browser” but sometimes “which era of that browser,” and a client that pins one frozen value set will, as the genuine browser moves on, age into a number nobody ships anymore.

The encoding itself leaks a little more than the values do. QUIC’s variable-length integers, defined in Section 16 of RFC 9000, use the top two bits of the first byte to say how many bytes the integer occupies: one, two, four, or eight. A value like 6,291,456 can legally be written in four bytes or eight, and an implementation that always pads to the larger width is distinguishable from one that uses the minimal encoding even when the decoded value is identical. The same is true of which parameters get sent with their default value spelled out versus omitted to let the default apply. Two stacks that agree on every effective limit can still differ on whether they bothered to encode ack_delay_exponent or max_ack_delay at all, and that presence-or-absence pattern is part of the fingerprint before any value is read. A naive reimplementation tends to encode everything, the long way, which is exactly the tell that separates it from a browser that was tuned for wire economy.

0-RTT, connection IDs, and the parameters that get cached

The fingerprint surface does not close when the handshake ends, because QUIC caches some of it. On a 0-RTT resumption the client reuses transport parameters remembered from the earlier connection, and the server gives the client an address-validation token on the first connection to present on the next one. The UCSD study watched Chrome do exactly this: it cached the token and replayed it, and the token it presented in a 0-RTT connection was different every time, which is the behaviour you want for privacy. Firefox in the same captures did not present 0-RTT tokens at all, which the authors read as either no caching or no use of the address-validation feature. That difference, 0-RTT used versus not, is one more binary that separates the two stacks without inspecting a single numeric value.

Connection-ID behaviour splits the same way. The study found that in Chrome the server-selected source connection ID was consistently zero-length, while in Firefox both the source and destination IDs were chosen by the client. The connection ID is implementation-chosen and its length is visible in the long header, so a stack that always uses 8-byte CIDs and one that uses zero-length CIDs are trivially told apart on the wire. The address-validation token has a length field of its own in the Initial packet, and the presence, length, and structure of that token across a session is yet another enumerable trait. None of this is hidden by QUIC’s encryption, because all of it lives in the handshake that has to happen in the clear before keys exist.

There is a subtlety worth stating plainly, because it changes who can do the fingerprinting. The transport parameters and the ClientHello are readable by any on-path observer, since the Initial keys come from public values. But several of the richer signals, 0-RTT token reuse patterns, connection-ID rotation across migrations, the timing of CRYPTO retransmissions, are only fully visible to the server, because they unfold over the course of a connection rather than sitting in one packet. So there are really two threat models here. A passive network observer gets the first-packet fingerprint. The server, or the edge in front of it, gets that plus the behavioural tail, which is the stronger position and the one anti-bot vendors occupy.

The ClientHello inside: QUIC inherits the whole TLS surface

Everything that makes a TLS ClientHello fingerprintable over TCP is present, unchanged, in the ClientHello QUIC carries. The cipher list, the extension list and its order, the supported groups, the signature algorithms, the key share, ALPN. QUIC did not invent a new handshake; it reused TLS 1.3 and moved the bytes into a CRYPTO frame. So the JA3 and JA4 machinery built for TCP applies directly, and the same per-browser quirks that the browser ClientHello catalog tracks show up here.

JA4 was built with this in mind. The first character of a JA4 fingerprint encodes the transport: q for QUIC, d for DTLS, t for plain TLS over TCP. Everything after that first character follows the same recipe as TCP. The JA4_a segment counts cipher suites and extensions and records the TLS version, the SNI/no-SNI flag, and the first and last characters of the ALPN value. JA4_b is a truncated SHA-256 of the sorted cipher list. JA4_c is a truncated SHA-256 of the sorted extension list followed by the signature algorithms. Sorting the ciphers and extensions was a deliberate choice to survive Chrome’s extension-order randomisation, the change that broke JA3. The QUIC and TCP fingerprints of the same browser usually share their JA4_b and JA4_c hashes, because it is the same TLS library underneath. That shared hash is itself a cross-check: a client whose QUIC JA4 and TCP JA4 disagree about cipher ordering is doing something a single browser would not.

What JA4 does not capture is the transport-parameter block. The QUIC fingerprinting test pages that compute a JA4 over QUIC note this directly: the current JA4 format does not cover QUIC transport parameters or the other QUIC connection artifacts. So a complete QUIC fingerprint is at least two hashes living side by side. One over the TLS ClientHello, which JA4 already standardises. One over the transport parameters, which it does not, and which the research and the reflection tools compute separately.

QUIC Initial packet ClientHello transport params JA4 (ciphers + extensions) q13d… — standardised transport-param hash 86cc… — not in JA4 One packet, two independent fingerprints. A faked client has to get both right, and keep them consistent. *A QUIC handshake yields two fingerprints from the same packet. JA4 covers the TLS half; the transport-parameter half is computed separately and is where most of the QUIC-specific signal lives.*

There is more in the packet than these two hashes, which is what the reflection tools surface. The clienthellod service, from the same group behind the QUIC mimicry work, parses a client Initial packet and emits separate fingerprint IDs for the whole ClientInitialPacket, the QUIC header, the QUIC ClientHello, and the transport-parameters combination. It also distinguishes a HexID computed over the observed extension order from a NormHexID computed over a sorted list, the same observed-versus-normalised split that lets a detector both catch order-sensitive tells and still match a client whose order was randomised. The QUIC long header itself adds fields a fingerprint can fold in: the advertised version, the connection-ID lengths, the token length, the packet-number length, and the order in which CRYPTO, PADDING, and PING frames appear in the Initial.

Version negotiation and the GREASE that holds it together

QUIC version 1 is 0x00000001. There is now a version 2, defined in RFC 9369, whose version number 0x6b3343cf was generated from the first four bytes of the SHA-256 of the string “QUICv2 version number.” Version 2 is deliberately almost identical to version 1; its job is to exercise the version-negotiation machinery and fight ossification, not to add features. Which versions a client offers, and how it reacts when a server pushes back, is itself a behavioural fingerprint.

The negotiation mechanism worth understanding is the compatible one in RFC 9368. A client lists the versions it supports in a version_information transport parameter. If the server’s preferred version shares a compatible first-flight format with the version the client used, the server can switch the connection to it without spending an extra round trip, by reading that list and encoding its choice in the long header. The security of this rests on the transport parameters being authenticated once the handshake completes, so a path attacker cannot silently downgrade. For fingerprinting, the version_information list is just one more enumerable field: the exact set of versions, and their order, is an implementation choice that differs between stacks.

GREASE is the counterweight to all of this. QUIC borrowed the idea from TLS. RFC 9000 reserves transport-parameter identifiers of the form 31 * N + 27 for any integer N, and requires endpoints to ignore transport parameters with an unknown identifier. A client that sprinkles a reserved identifier into its parameter list is checking that the peer really does ignore unknowns, which keeps middleboxes and servers from hard-coding the exact parameter set and breaking future clients. There is a second piece, RFC 9287, which registers a grease_quic_bit transport parameter at 0x2ab2; sent empty, it tells the peer it may flip the second-most-significant bit of the first byte of QUIC packets, a bit that older middleboxes assumed was fixed.

GREASE cuts both ways for fingerprinting. The intent is to add entropy and prevent ossification, but the way a client greases is itself a tell. Whether a client sends a reserved parameter at all, where it places it in the list, whether it negotiates the QUIC bit, whether it greases its version offers: each of those is a yes-or-no that real browsers answer in their own consistent way. The same thing happened in TLS, where the pattern of GREASE values in the ClientHello became a fingerprint input rather than a defence against one. Randomness that is generated by a fixed algorithm is still a fixed algorithm, and the algorithm has a signature.

Client Server Initial (version 1) + version_information = [v1, v2, …] Initial (version 2, chosen from the list) — no extra RTT The version list, its order, and whether it is greased are all enumerable client traits. *Compatible version negotiation (RFC 9368). The client's offered version list rides in a transport parameter, which makes the negotiation fast and also makes the list a fingerprint field.*

The HTTP/3 layer adds its own tells

Above QUIC sits HTTP/3, defined in RFC 9114, and it carries its own fingerprint surface that mirrors HTTP/2’s. The first frame on the HTTP/3 control stream is a SETTINGS frame, and its parameter values are an implementation choice the same way the HTTP/2 SETTINGS frame is. HTTP/3 also replaced HPACK with QPACK, because HPACK assumed in-order delivery that QUIC does not guarantee, and QPACK’s use of separate encoder and decoder streams gives a client more behaviour to vary and therefore more to fingerprint.

Pseudo-header order survives the jump from HTTP/2 to HTTP/3 as cleanly as the TLS surface does. Each browser emits its request pseudo-headers in a fixed order, and the orders differ: Chrome sends :method, :authority, :scheme, :path; Firefox sends :method, :path, :authority, :scheme; Safari sends :method, :scheme, :path, :authority. A client that gets the QUIC transport parameters perfectly and then emits the wrong pseudo-header order has undone its own work. This is the same pseudo-header ordering tell that catches HTTP/2 clients, lifted into HTTP/3 unchanged, and it is the kind of consistency requirement that makes a convincing forgery expensive: every layer has to agree on which browser it is pretending to be.

The discovery path matters too. A browser does not start on HTTP/3. It connects over TCP and TLS, and the server advertises HTTP/3 with an Alt-Svc: h3=... response header (or an HTTPS DNS record); only then does the browser try QUIC. Most scraping libraries are HTTP/2-only and never make that jump, so a client that ignores a server’s h3 advertisement and stays on TCP, when a real browser would have upgraded, is itself anomalous. The absence of an HTTP/3 connection from a client claiming to be a current Chrome is a signal before any QUIC packet is even inspected.

Why detection here is still near-greenfield

Server-side QUIC fingerprinting is real and the tooling exists, but it is not yet deployed at the saturation that HTTP/2 and TLS fingerprinting reached. The honest read from the people building detection is that HTTP/3 fingerprinting is still maturing, with Cloudflare and Google leading server-side adoption and the anti-bot products not yet matching their HTTP/2-level coverage. That gap is not because QUIC is hard to fingerprint. The packet is right there in cleartext. It is because of adoption math and operational friction.

HTTP/3’s share of traffic is large but not total. Roughly a third of requests to Cloudflare arrive over HTTP/3, and about 35 percent of sites advertise it, which means a detector that only inspected QUIC would miss most of the traffic it cares about. More to the point for the people writing bot rules, a client that wants to avoid QUIC fingerprinting can simply not speak QUIC. UDP 443 is far more often blocked or rate-limited by middleboxes than TCP 443, browsers already fall back to TCP gracefully, and a scraper can decline the Alt-Svc upgrade and stay on HTTP/2 forever. So the immediate incentive to harden QUIC detection is muted: the population that reaches it is self-selected, and the evader’s escape hatch is to use the older transport that everyone already fingerprints well.

The mimicry tooling has arrived early, which is the usual signal that the detection it answers is about to. The refraction-networking group’s uQUIC, a fork of quic-go, exposes the unencrypted Initial packet for customisation: the QUIC header, the CRYPTO, PADDING, and PING frames, the TLS ClientHello through its uTLS integration, and the transport parameters carried in the uTLS extensions. Its own README is candid that the result may not be realistically indistinguishable from the client it imitates, which is the recurring truth of this whole field. Imitating one layer is straightforward; imitating every layer consistently, and keeping the imitation current as the genuine client moves, is the hard part, and it is the same reason stealth approaches lose ground over time.

What tips this from greenfield to deployed is a single high-traffic vendor flipping the transport-parameter hash from “collected” to “scored.” The signal is sitting in the first packet of every QUIC connection already; Cloudflare’s edge captures the QUIC handshake the same way it captures the TLS and HTTP/2 handshakes it already scores. The reason a Go client wearing a perfect Chrome ClientHello still gets caught is the second hash, the one over initial_max_data and initial_max_streams_uni and the rest, that no amount of TLS mimicry touches. When the transport-parameter hash and the JA4-over-QUIC hash and the HTTP/3 pseudo-header order all have to name the same browser, and that browser’s real values change every few releases, the forgery has to be rebuilt on the browser’s schedule rather than the forger’s. The bytes have been readable since the first version of QUIC shipped. What has been missing is only the will to read them at scale, and that is a deployment decision, not a research problem.


Sources & further reading

Further reading