QUIC Changes the Fingerprint Game: Why Your Proxy Stack Silently Downgrades to HTTP/2
A few years ago, the fingerprinting conversation in scraping was all about TLS: match the JA3, fix the header order, stop leaking Python through your ClientHello. Then the conversation moved up one layer to HTTP/2: SETTINGS frames, HPACK behavior, pseudo-header order. Both conversations assume something that is quietly becoming false — that your scraper and the origin meet over a TCP-based protocol where ALPN picks between h2 and http/1.1.
The origins you scrape are increasingly advertising HTTP/3. The QUIC handshake underneath it is a fingerprint surface that is, counterintuitively, more readable to on-path observers than TLS, because the encryption protecting it starts from well-known keys. And the proxy layer that most scraping stacks depend on cannot carry QUIC at all. The result is a silent downgrade: your client, your proxy, and the origin negotiate their way back to HTTP/2 every single time, and almost nobody logs that this is happening.
The core idea of this article: the h2 downgrade imposed by your proxy stack is a fact about your traffic that you should measure, log, and treat as a routing decision — not an accident you discover the day an origin starts treating "residential IP that never attempts QUIC on repeat visits" as a bot signal.
What actually happens when a proxied client meets an h3 origin
Walk through the negotiation and the downgrade becomes obvious.
When a browser visits an HTTP/3-capable origin for the first time, it connects over TCP with ALPN offering h2 (and http/1.1). The origin responds with an alt-svc header advertising QUIC:
alt-svc: h3=":443"; ma=86400
On subsequent visits within the ma (max-age) window, the browser races a QUIC connection in parallel with the TCP one and switches if QUIC wins. That is the browser behavior model: first visit TCP, later visits increasingly QUIC.
Now insert a typical residential or datacenter proxy between your client and that origin. An HTTP CONNECT tunnel carries exactly one TCP stream. QUIC is UDP. There is no HTTP-semantics way to push UDP through CONNECT — the protocol simply doesn't have a verb for it. SOCKS5 technically has a UDP ASSOCIATE command, but support across commercial proxy providers is rare, and the Python HTTP clients you actually use don't implement QUIC anyway. The practical consequence:
-
requestsandhttpxnever speak QUIC, proxied or not. -
aioquicspeaks QUIC but has no HTTP-proxy support, so it can't traverse the tunnel you're paying for. - Your client through the proxy offers ALPN
h2at most. The downgrade isn't a negotiation failure — h3 was never on the table.
So through a proxy, the interaction with an h3-advertising origin is always: connect over TCP, negotiate h2, receive an alt-svc advertisement, ignore it, repeat. Every request. Forever. If the origin or its anti-bot vendor is tracking protocol behavior per client, your entire fleet — hundreds or thousands of residential-looking exits — exhibits one uniform, synthetic behavior: never attempts QUIC, ever.
What QUIC fingerprinting actually reads
The second reason to care is that QUIC handshakes are fingerprintable in a way that's easy to underestimate. QUIC's Initial packets are "encrypted," but the keys are derived from the version number and the destination connection ID, both sent in cleartext. Any on-path observer — including the origin's edge — can decrypt Initial packets and read:
- the QUIC version being offered,
- the connection ID lengths,
- the TLS ClientHello embedded in the CRYPTO frame (so everything JA3/JA4 saw is still visible),
- the QUIC transport parameters, which encode implementation-specific choices — initial flow-control windows, max UDP payload size, ACK frame expiry, and the presence or absence of optional parameters.
That last item is the new surface. Just as HPACK and SETTINGS behavior distinguish HTTP/2 implementations, transport parameters distinguish QUIC implementations: Chrome's stack, msquic, picoquic, quiche, and aioquic each produce a recognizably different parameter set. Anti-bot vendors that already fingerprint TLS and h2 have an obvious incentive to fingerprint h3 the same way, and the h3 population is still small enough that being in it with a weird implementation would be more anomalous, not less.
The honest engineering summary is asymmetric: through a TCP-only proxy you never present a QUIC fingerprint at all, so you can't leak a bad one. The risk you carry instead is behavioral — the absence of any h3 attempt across your whole fleet — and that risk is currently speculative. This article's position is not "panic"; it is "stop being ignorant of the gap." Speculative risks that apply to 100% of your traffic deserve a metric and a policy, not a shrug.
Measuring the gap: an Alt-Svc harvester and downgrade ledger
The first concrete deliverable is cheap: harvest alt-svc advertisements from the origins you actually crawl, so you know the size of the gap. The second is a ledger: record what you negotiated per request, so the downgrade becomes visible in your telemetry instead of invisible in your stack.
import httpx
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
def probe_origin(client: httpx.Client, url: str) -> dict:
"""One fetch through the proxy; return what we negotiated and what
the origin advertised. Requires httpx with http2=True (pip install
httpx[http2]) so ALPN can actually produce h2."""
r = client.get(url, headers={"user-agent": UA, "accept": "text/html"})
alt_svc = r.headers.get("alt-svc", "")
advertises_h3 = 'h3=' in alt_svc
return {
"url": url,
"negotiated": r.http_version, # "HTTP/2" or "HTTP/1.1"
"alt_svc": alt_svc,
"origin_advertises_h3": advertises_h3,
"downgraded_by_proxy": advertises_h3 and r.http_version != "HTTP/3",
}
def harvest(origins: list[str], proxy_url: str) -> list[dict]:
limits = httpx.Limits(max_connections=8)
with httpx.Client(
http2=True,
proxy=proxy_url,
timeout=httpx.Timeout(10.0, read=20.0),
limits=limits,
follow_redirects=False,
) as client:
return [probe_origin(client, u) for u in origins]
if __name__ == "__main__":
rows = harvest(
["https://example-a.com/", "https://example-b.com/"],
"http://user:pass@gate.example.proxy:8000",
)
for row in rows:
print(row)
Run this daily over your origin list and you get the denominator for everything else: how many of your targets prefer a protocol you cannot speak through your current egress. Feed the rows into a policy builder:
def build_protocol_policy(rows: list[dict]) -> dict:
"""Per-origin protocol policy. Today the only honest proxied options are
h2 and http/1.1; the interesting question is which one to *insist* on."""
policy = {}
for row in rows:
if row["origin_advertises_h3"]:
# Gap: origin prefers h3, we always land on h2 through the tunnel.
# Keep h2 (it is what real browsers use on first visit anyway),
# but tag the origin for re-evaluation when your provider offers
# UDP relay or when you decide some origins justify unproxied h3
# fetches from infrastructure you control.
policy[row["url"]] = {"speak": "h2", "note": "h3 gap, revisit"}
else:
policy[row["url"]] = {"speak": "h2", "note": ""}
return policy
Note what the policy is not doing: it is not pretending you chose h2. The note field keeps the downgrade visible, and the daily harvest tells you when the gap grows.
Verifying what you actually negotiated
Self-reporting clients lie. httpx will happily tell you HTTP/2 while a TLS-terminating proxy tier downgraded your connection to HTTP/1.1 on the origin leg — you negotiated h2 with the proxy, not with the target. If your provider's gateway re-originates TLS (common on some ISP and datacenter tiers), the protocol the origin sees is whatever the gateway speaks, not what you asked for.
The fix is the same egress-canary pattern that has served pool health work well: an echo endpoint you control, which reports the protocol version and ALPN result it observed on its side of the connection.
def verify_negotiated_protocol(proxy_url: str, echo_url: str) -> dict:
"""Ask your own echo server what it saw. Compare with the client-side
view; disagreement means the proxy path re-originates or downgrades."""
with httpx.Client(http2=True, proxy=proxy_url, timeout=15.0) as c:
r = c.get(echo_url, headers={"user-agent": UA})
observed = r.json() # {"http_version": "HTTP/2", "alpn": "h2", ...}
return {
"client_believes": "HTTP/2",
"origin_saw": observed.get("http_version"),
"coherent": observed.get("http_version") == "HTTP/2",
}
Run verify_negotiated_protocol against a sample of exits on a schedule, exactly as you would any other canary. An exit that silently speaks HTTP/1.1 to origins while your client believes otherwise is telling you the gateway terminates TLS on that tier — which changes your whole fingerprint model for that tier, because the origin-side hello is the gateway's, not yours.
The decision framework
You do not need QUIC today. You need to stop drifting into the h3 era by accident. Concretely:
-
Harvest
alt-svcon every origin you crawl, daily. This is your gap inventory. It costs one header read per request you were making anyway. - Log negotiated protocol on a sample of production traffic, and reconcile client-side belief against an echo endpoint on a sample of exits. Downgrades and gateway TLS termination are pool properties, and they change without notice.
- Pin h2 explicitly per origin rather than accepting whatever falls out of the stack. An explicit pin turns "we couldn't do h3" into "we run h2 by policy; the origin's h3 preference is tracked as a gap."
- Re-evaluate when your egress options change. If a provider adds UDP relay or a SOCKS5-UDP path that your client stack can actually use, the gap inventory tells you immediately which origins are worth testing h3 against — and then fingerprint coherence for QUIC (transport parameters matching your h2/TLS persona's browser family) becomes the next conformance test to build.
The quiet conclusion is that protocol negotiation is now part of proxy engineering. The origin-facing story your traffic tells is no longer just "does this IP look residential" but "does this client behave like the browsers that use this kind of IP" — and browsers, on repeat visits, increasingly attempt QUIC. You cannot fix that through a TCP tunnel, but you can measure it, log it, and make the downgrade a decision with an owner and a review date. That is the difference between a fleet that looks like one synthetic thing and a fleet that just looks like it upgrades its browser a little later than everyone else.
Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele
Top comments (0)