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_size alone differs (1472 vs 1452), and so do the flow control windows and the presence of version_information and max_datagram_frame_size.
  • One field changes the hash. A single different ack_delay_exponent moves 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_bidi and initial_max_streams_uni produces 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-Agent for Chrome on desktop or Android, both of which would use h3, and then never attempting it;
  • a profile that reports navigator.connection values that only an h2 client would produce, since h3 changes the effective connection type a browser reports;
  • advertising Alt-Svc support 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-Svc round 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_migration to 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.