QUIC, HTTP/3 and Transport Fingerprints
The Third Transport Is a Fingerprint and a Trap
HTTP/3 moves the request onto QUIC, which puts TLS 1.3 inside UDP with its own framing, its own congestion control and its own connection identity. The HTTP/2 and HTTP/3 lesson covers the protocol mechanics. What matters here is that a QUIC handshake leaks to a server something a TCP handshake does not, and that a client which cannot speak QUIC is invisible in a way that is easy to mistake for safety.
What a Server Sees Without Breaking Any Encryption
In TCP plus TLS, the ClientHello arrives in the clear and the server terminates TLS. In QUIC, the ClientHello is inside a CRYPTO frame, but the Initial packets carrying it are encrypted with keys derived from the Destination Connection ID using a publicly specified algorithm (RFC 9001). Anyone who knows the connection ID can derive those keys. They are not secret; they exist so that only the endpoint can read the rest of the flight.
So a server that terminates the handshake sees, in the clear or cheaply derivable:
- the entire QUIC ClientHello and therefore the same TLS-level signals as a TCP client, plus everything specific to QUIC,
- the transport parameters the client advertises, which have no equivalent in a TCP handshake,
- the initial packet sizes and their sequence, which reveal the client's path MTU assumptions and congestion controller,
- the connection IDs, which are per-connection but whose length and format are client-chosen.
The transport parameters are the interesting surface, because they are pure client policy, expressed as data, and different stacks choose different values.
The Transport Parameter List
Transport parameters are a sequence of (id, length, value) triples, varint-encoded, sent in the TLS ClientHello of a QUIC connection:
| Id | Name | What a client is telling the server |
|---|---|---|
| 0x00 | original_destination_connection_id |
the CID the packet was sent to, for retry validation |
| 0x01 | max_idle_timeout |
how long this connection survives with no traffic |
| 0x03 | max_udp_payload_size |
the largest datagram the client will handle, 1200 to 65527 |
| 0x04 | initial_max_data |
connection-level flow control credit |
| 0x05-07 | initial_max_stream_data_* |
per-stream flow control credit, three separate limits |
| 0x08-09 | initial_max_streams_bidi / _uni |
how many streams it will open |
| 0x0a | ack_delay_exponent |
how precisely it timestamps acknowledgements |
| 0x0b | max_ack_delay |
the longest it will delay an ack |
| 0x0c | disable_active_migration |
set when the client will not change addresses |
| 0x0e | active_connection_id_limit |
how many CIDs the client will hold |
| 0x0f | initial_source_connection_id |
the CID the client chose to send from |
| 0x173e | version_information |
which QUIC versions the client supports (RFC 9368) |
| 0x6474 | max_datagram_frame_size |
the WebTransport/QUIC datagram budget |
Chrome's desktop defaults are recognisable: max_udp_payload_size 1472 (the safe value under a 1500-byte MTU), initial_max_data 10 MB, 100 bidirectional and 100 unidirectional streams, ack_delay_exponent 3, max_ack_delay 25 ms, disable_active_migration set, and a datagram budget. A Go or Rust QUIC library picks noticeably different numbers because it is optimising for a different deployment.
GREASE applies here too. RFC 9000 reserves transport parameter ids, and clients insert them with an empty payload. As on the TLS side, a collector strips them before hashing and keeps the pattern of where they appeared.
Hashing a Parameter Set
The measurement idea from the JA3 lesson applies directly: normalise the parameters, drop the per-connection noise (the connection IDs, which change every connection and are not identity), drop the reserved values, serialise the rest into a canonical string, and hash it. The result is a QUIC transport fingerprint, sometimes called a QTP hash.
# QUIC transport-parameter fingerprint snippet: deterministic hash over the
# advertised parameter set, printed for two sample clients.
import hashlib
# RFC 9000 section 18.2 parameter ids that appear in a real browser ClientHello.
PARAMS = {
0x00: "original_destination_connection_id",
0x01: "max_idle_timeout",
0x02: "stateless_reset_token",
0x03: "max_udp_payload_size",
0x04: "initial_max_data",
0x05: "initial_max_stream_data_bidi_local",
0x06: "initial_max_stream_data_bidi_remote",
0x07: "initial_max_stream_data_uni",
0x08: "initial_max_streams_bidi",
0x09: "initial_max_streams_uni",
0x0A: "ack_delay_exponent",
0x0B: "max_ack_delay",
0x0C: "disable_active_migration",
0x0D: "preferred_address",
0x0E: "active_connection_id_limit",
0x0F: "initial_source_connection_id",
0x10: "retry_source_connection_id",
0x173E: "version_information",
0x6474: "max_datagram_frame_size",
}
def is_grease_parameter(pid):
# RFC 9000 reserved ids are two copies of one GREASE 16-bit value (0x?a?a).
for half in (pid >> 16, pid & 0xFFFF):
if (half >> 8) != (half & 0xFF) or not 0x0A <= (half >> 8) <= 0xFA:
return False
return True
CHROME_DESKTOP = [
(0x00, bytes.fromhex("0a1b2c3d4e5f6a7b")),
(0x01, (30 * 1000).to_bytes(8, "big")),
(0x03, (1472).to_bytes(2, "big")),
(0x04, (10 * 1024 * 1024).to_bytes(8, "big")),
(0x05, (2 * 1024 * 1024).to_bytes(8, "big")),
(0x06, (2 * 1024 * 1024).to_bytes(8, "big")),
(0x07, (1024 * 1024).to_bytes(8, "big")),
(0x08, (100).to_bytes(2, "big")),
(0x09, (100).to_bytes(2, "big")),
(0x0A, (3).to_bytes(1, "big")),
(0x0B, (25).to_bytes(1, "big")),
(0x0C, b"\x00"),
(0x0E, (2).to_bytes(1, "big")),
(0x0F, bytes.fromhex("f0e1d2c3b4a59687")),
(0x6474, (65536).to_bytes(8, "big")),
(0x6A6A6A6A, b""), # reserved grease value, empty
]
QUIC_GO_LIBRARY = [
(0x00, bytes.fromhex("0001020304050607")),
(0x01, (5000).to_bytes(8, "big")),
(0x03, (1452).to_bytes(2, "big")),
(0x04, (100 * 1024).to_bytes(8, "big")),
(0x05, (65536).to_bytes(8, "big")),
(0x06, (65536).to_bytes(8, "big")),
(0x07, (65536).to_bytes(8, "big")),
(0x08, (100).to_bytes(2, "big")),
(0x09, (100).to_bytes(2, "big")),
(0x0A, (3).to_bytes(1, "big")),
(0x0B, (25).to_bytes(1, "big")),
(0x0E, (2).to_bytes(1, "big")),
(0x0F, bytes.fromhex("1112131415161718")),
(0x173E, bytes.fromhex("0000000100000001")),
]
def parameter_name(pid):
# Known ids by name; a GREASE id and anything else by shape.
if pid in PARAMS:
return PARAMS[pid]
if is_grease_parameter(pid):
return "grease"
return f"unknown_0x{pid:04x}"
def render(pairs):
# Serialise a parameter set the way a collector would before hashing.
parts = []
for pid, value in pairs:
name = parameter_name(pid)
if name in ("original_destination_connection_id",
"initial_source_connection_id",
"stateless_reset_token"):
shown = value.hex() # per-connection noise, not identity
else:
shown = str(int.from_bytes(value, "big")) if value else "0"
parts.append(f"{name}(0x{pid:02x})={shown}")
return ",".join(parts)
def fingerprint(pairs, strip_connection_ids=True):
# A JA3-style hash: same idea, QUIC transport parameters instead of ciphers.
keep = []
for pid, value in pairs:
if strip_connection_ids and pid in (0x00, 0x0F, 0x10):
value = b"" # drop per-connection randomness
if is_grease_parameter(pid):
continue # and drop the reserved values
keep.append((pid, value))
return hashlib.sha256(render(keep).encode()).hexdigest()[:16]
def show(label, pairs):
print(f"=== {label} ===")
print(f"{len(pairs)} transport parameters advertised")
for pid, value in pairs:
note = ""
if pid in (0x00, 0x0F):
note = " (changes every connection)"
print(f" 0x{pid:08x} {parameter_name(pid):34} "
f"{len(value):>4} B{note}")
print(f" qtp-fingerprint: {fingerprint(pairs)}")
print()
show("Chrome 124 on a desktop", CHROME_DESKTOP)
show("quic-go based client", QUIC_GO_LIBRARY)
print("single-field sensitivity")
base = fingerprint(CHROME_DESKTOP)
edits = [
("max_udp_payload_size 1472 -> 1452", [(0x03, (1452).to_bytes(2, "big"))]),
("ack_delay_exponent 3 -> 2", [(0x0A, (2).to_bytes(1, "big"))]),
("drop max_datagram_frame_size", None),
("reorder: swap 0x08 and 0x09", None),
]
for label, patch in edits:
if label.startswith("drop"):
variant = [(p, v) for p, v in CHROME_DESKTOP if p != 0x6474]
elif label.startswith("reorder"):
variant = [(0x09 if p == 0x08 else 0x08 if p == 0x09 else p, v)
for p, v in CHROME_DESKTOP]
else:
variant = [(p, patch[0][1] if p == patch[0][0] else v) for p, v in CHROME_DESKTOP]
got = fingerprint(variant)
print(f" {label:38} {base} -> {got} {'SAME' if got == base else 'changed'}")
noisy = fingerprint(CHROME_DESKTOP)
striped = [(p, (bytes.fromhex("aabbccdd11223344") if p == 0x0F else v))
for p, v in CHROME_DESKTOP]
print(f" {'new connection id, same client':38} {noisy} -> {fingerprint(striped)} "
f"{'SAME' if fingerprint(striped) == noisy else 'changed'}")
=== Chrome 124 on a desktop ===
16 transport parameters advertised
0x00000000 original_destination_connection_id 8 B (changes every connection)
0x00000001 max_idle_timeout 8 B
0x00000003 max_udp_payload_size 2 B
0x00000004 initial_max_data 8 B
0x00000005 initial_max_stream_data_bidi_local 8 B
0x00000006 initial_max_stream_data_bidi_remote 8 B
0x00000007 initial_max_stream_data_uni 8 B
0x00000008 initial_max_streams_bidi 2 B
0x00000009 initial_max_streams_uni 2 B
0x0000000a ack_delay_exponent 1 B
0x0000000b max_ack_delay 1 B
0x0000000c disable_active_migration 1 B
0x0000000e active_connection_id_limit 1 B
0x0000000f initial_source_connection_id 8 B (changes every connection)
0x00006474 max_datagram_frame_size 8 B
0x6a6a6a6a grease 0 B
qtp-fingerprint: e3dc856c4cb03ffc
=== quic-go based client ===
14 transport parameters advertised
0x00000000 original_destination_connection_id 8 B (changes every connection)
0x00000001 max_idle_timeout 8 B
0x00000003 max_udp_payload_size 2 B
0x00000004 initial_max_data 8 B
0x00000005 initial_max_stream_data_bidi_local 8 B
0x00000006 initial_max_stream_data_bidi_remote 8 B
0x00000007 initial_max_stream_data_uni 8 B
0x00000008 initial_max_streams_bidi 2 B
0x00000009 initial_max_streams_uni 2 B
0x0000000a ack_delay_exponent 1 B
0x0000000b max_ack_delay 1 B
0x0000000e active_connection_id_limit 1 B
0x0000000f initial_source_connection_id 8 B (changes every connection)
0x0000173e version_information 8 B
qtp-fingerprint: b85b44262342b2da
single-field sensitivity
max_udp_payload_size 1472 -> 1452 e3dc856c4cb03ffc -> 25e71bf842ed7297 changed
ack_delay_exponent 3 -> 2 e3dc856c4cb03ffc -> 14472561969990e2 changed
drop max_datagram_frame_size e3dc856c4cb03ffc -> 71a7e88e52fbffa4 changed
reorder: swap 0x08 and 0x09 e3dc856c4cb03ffc -> 334dcd2b84a4eeae changed
new connection id, same client e3dc856c4cb03ffc -> e3dc856c4cb03ffc SAME
Four things the output shows:
- The two clients do not collide, and neither of them is a coincidence.
max_udp_payload_sizealone differs (1472 vs 1452), and so do the flow control windows and the presence ofversion_informationandmax_datagram_frame_size. - One field changes the hash. A single different
ack_delay_exponentmoves it completely. There is no partial credit, which means there is no "close enough" to aim for; you either reproduce a known set or you are a new entry in the vendor's table. - Reordering matters, exactly as with cipher suites. Swapping
initial_max_streams_bidiandinitial_max_streams_uniproduces a different hash even though the set is identical. - Connection IDs are correctly excluded. A new connection ID with the same client yields the same hash, which is what makes the fingerprint usable at all.
The TLS resumption lesson has the same structure for the ClientHello itself, and the same warning: strip the reserved values before hashing, but keep their positions, because a client that gets the hashed part right and the pattern wrong is more suspicious than one that got both wrong.
0-RTT and Why a WAF Treats It Differently
With a session ticket, a QUIC client can send 0-RTT early data in its first flight. The server cannot yet prove which ticket the client is claiming, so it processes the request before the handshake completes. That data is replayable by anyone who recorded it, so a correct server accepts 0-RTT only for idempotent requests and marks the response so the client knows it was early data.
A WAF that sees 0-RTT on a POST has a real signal, and it is not a fingerprint at all: the client is attempting a replayable request, which browsers do not do. More practically for detection, 0-RTT is rare in scraping traffic, because a library that supports it also supports resumption, and a client with no ticket cache cannot produce it. A population that suddenly starts sending 0-RTT after a week of full handshakes is a population that changed its tooling.
Connection Migration as a Population Signal
QUIC connections survive a network change because the client sends packets with a new source address under the same connection ID, and the server validates it with the PATH_CHALLENGE/PATH_RESPONSE exchange. This is genuinely useful on mobile, and it has a fingerprint consequence: real clients migrate, scripted clients usually do not. A QUIC connection that lives for minutes from a single address, never changing CID, is a data-centre-shaped pattern, because data-centre NAT does not move.
The reverse also holds. A connection that changes address mid-stream, on a desktop with a fixed uplink, is either a VPN reconnecting or something deliberately rotating, and both are worth a look.
Falling Back Is Itself Observable
A browser finds out a site speaks HTTP/3 from the Alt-Svc response header over HTTP/2, or from the DNS HTTPS record before any connection exists. The first visit is typically HTTP/2, then the connection races QUIC against TCP, and later visits go straight to h3.
That sequence is a signal in itself. A client that receives Alt-Svc: h3=":443"; ma=86400 and then never opens a UDP 443 packet has told the site it does not support HTTP/3, which excludes most desktop browsers and every mobile browser. Falling back once is normal; never attempting is a statement.
The observable is the round trip. A failed QUIC attempt costs the connection a timeout before it retries over TCP, and that latency shows up as a slower first byte. A site that measures time-to-first-byte per visitor can distinguish "no h3" from "slow h3" without decrypting anything.
The Mismatch Is the Real Risk
A Python scraper with no HTTP/3 support is not detectable by HTTP/3. It never appears in the QUIC tables at all. The exposure comes from the contradictions around it:
- claiming a
User-Agentfor Chrome on desktop or Android, both of which would use h3, and then never attempting it; - a profile that reports
navigator.connectionvalues that only an h2 client would produce, since h3 changes the effective connection type a browser reports; - advertising
Alt-Svcsupport in a header grammar copied from a browser while the transport below is HTTP/1.1; - running through a proxy that terminates TCP, so the origin never sees the h3 the edge saw.
None of these require the site to speak QUIC at all. They are consistency failures, and Device Profile Consistency covers the general form.
What a Python Stack Can Actually Do
| Client | HTTP/3 support | How |
|---|---|---|
| Playwright / Chromium | yes, in the browser | real QUIC, real transport parameters, the full stack |
curl |
only if built with it | curl -V must list HTTP3; most distro builds do not |
curl_cffi |
no | BoringSSL-derived TLS, but TCP transport only |
httpx / requests / aiohttp |
no | HTTP/1.1 and HTTP/2 over TCP |
aioquic |
yes, a real QUIC stack | Python-native, and its parameters are not Chrome's |
The last row is the trap. aioquic gives you genuine HTTP/3, but a Python library's transport parameters are a Python library's. A WAF that hashes parameter sets will have it in the table immediately, and the profile now says "Chrome" while the transport says "aioquic". Driving a real Chromium is the only way to get a parameter set that belongs to the browser you are claiming to be, which is the same conclusion the TLS lessons reach from a different direction.
A Checklist for the HTTP/3 Half
- Do not advertise what you cannot use. If the profile claims a browser that speaks h3, the site expects a UDP 443 packet.
- Watch the
Alt-Svcround trip. A permanently slower first byte on a modern browser profile is a h3 failure in progress. - If you do use QUIC, use a browser's QUIC. A Python QUIC library carries its own transport fingerprint.
- Reproduce transport parameters as a set. Partial edits change the hash completely; there is no near miss.
- Exclude connection IDs, keep the reserved-value pattern. The same rule as GREASE on the TLS side.
- Expect
disable_active_migrationto be set. A client that does not set it, and never migrates, is odd in a different way. - Treat 0-RTT on non-idempotent requests as a red flag in your own logs; a scraper has no business sending it.
The Legitimate Route
Measuring the transport parameters your own browser advertises, so you can confirm the profile you are presenting is internally consistent, is ordinary diagnostic work, and the hash above is a reasonable way to do it. What is not ordinary is using knowledge of a specific site's QUIC fingerprint database to impersonate a browser you are not, or to defeat a rate limit or bot policy that the site applies specifically to HTTP/3 clients. Where a site publishes an Alt-Svc policy or an API rate limit, respect it; the same boundary described in Legal and Compliance Boundaries applies here as everywhere else.
Cross-links: TLS Resumption, ALPN, GREASE & HelloRetryRequest, TLS & JA3 Spoofing, HTTP/2 and HTTP/3 (QUIC), Device Profile Consistency, Browser Proxies and DNS Leaks, Signals and TLS config.