Does Your Proxy Rewrite Your Handshake? Auditing HTTP/2 Fingerprint Integrity Across Exit Tiers
Here is an assumption that costs teams weeks of debugging: a CONNECT tunnel is transparent, so whatever TLS/HTTP fingerprint my client produces is what the target server sees.
Most of the time it's true. When it isn't, your scraper fails in the most infuriating way possible — the same code, the same target, works through one proxy tier and gets challenged through another, and your logs show nothing wrong because nothing is wrong on your side. The fingerprint changed somewhere between your process and the target, and you never measured it.
This post is about measuring it: treating your HTTP/2 fingerprint as a property of the entire route — client → proxy gateway → exit → target — and auditing it per exit tier instead of assuming it.
What the h2 fingerprint is, in one paragraph
When an HTTP/2 connection opens, the client sends a SETTINGS frame, a WINDOW_UPDATE, and a stream of PRIORITY frames before any request bytes. Akamai's fingerprint format serializes this preamble into a hash. Unlike JA3, which only covers the TLS ClientHello, the h2 fingerprint captures how your HTTP stack is configured — header table size, initial window, max concurrent streams, and the priority tree. Anti-bot systems read both, and disagreement between them is itself a signal: a Chrome-looking JA3 with a Python-looking h2 fingerprint is the signature of a naive impersonation attempt. (I wrote about the client side of that mismatch previously; today is about the path.)
Four ways a proxy path silently breaks it
1. ALPN downgrade at the gateway. Some legacy proxy stacks — you find this in older datacenter proxy deployments more than anywhere else — terminate your connection at the gateway and re-originate it as HTTP/1.1 toward the exit, because their internal forwarding path never got an h2 upgrade. Your client thinks it negotiated h2 with the target. The target sees HTTP/1.1 from a residential IP. Any fingerprint you carefully configured never arrived.
2. TLS-terminating (MITM) gateways. Some filtering gateways decrypt, inspect, and re-encrypt. Your beautifully impersonated Chrome ClientHello ends at the gateway; the target sees the gateway's GnuTLS or OpenSSL-default handshake, which matches nothing a real browser ever produced. This is rare with reputable residential providers' tunneling paths, but it's exactly the kind of thing that differs between a provider's "standard" and "enterprise" gateways, and between a provider's entry and relay regions.
3. Frame stripping. CONNECTION coalescing and PRIORITY frames sometimes get mangled by intermediary NAT or HTTP/2-aware middleboxes, changing the Akamai hash even when the protocol version survives.
4. Exit-side stack divergence. On mobile-carrier exits, some providers run their own optimization proxies (carrier-grade middleboxes that "helpfully" rewrite headers). The fingerprint is a property of the last mile, too.
None of these show up in your client logs. The only reliable evidence lives at the target side — or at an echo endpoint that reports what it saw.
The audit: an end-to-end fingerprint probe
The design is simple: for each proxy route you use, make one request to a TLS/HTTP echo service through that route, and compare what arrived against a direct (non-proxied) baseline. Drift means the route is not transparent.
# pip install httpx[http2]
import httpx
import json
from dataclasses import dataclass, field
ECHO_URL = "https://tls.peet.ws/api/all" # reports ja3, ja4, akamai h2 hash,
# and http_version as seen by the server
@dataclass
class RouteReport:
label: str
ok: bool = False
http_version: str = ""
ja4: str = ""
h2_hash: str = ""
error: str = ""
def probe(label: str, proxy: str | None, baseline=None) -> RouteReport:
rep = RouteReport(label=label)
try:
with httpx.Client(http2=True, proxy=proxy, timeout=30,
headers={"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"}) as c:
r = c.get(ECHO_URL)
data = r.json()
rep.ok = True
rep.http_version = r.http_version # "HTTP/2" or "HTTP/1.1"
rep.ja4 = data.get("tls", {}).get("ja4", "")
h2 = data.get("http2", {}) or {}
# peet.ws exposes the akamai-style hash under http2
rep.h2_hash = h2.get("akamai_fingerprint", "")
except Exception as e:
rep.error = str(e)
return rep
def audit(routes: dict[str, str | None]) -> list[RouteReport]:
baseline = probe("direct-baseline", None)
print(f"baseline: {baseline.http_version} ja4={baseline.ja4} "
f"h2={baseline.h2_hash[:24]}")
reports = []
for label, proxy in routes.items():
rep = probe(label, proxy)
verdict = "OK" if (rep.ok and
rep.http_version == baseline.http_version and
rep.ja4 == baseline.ja4) else "DRIFT"
print(f"{label:24s} {verdict:6s} "
f"v={rep.http_version:10s} ja4={rep.ja4[:20]:22s} "
f"h2={rep.h2_hash[:20]:22s} {rep.error}")
reports.append(rep)
return reports
if __name__ == "__main__":
# One route per exit tier you actually buy. Session IDs pin exits so
# the probe measures a stable route, not a random draw each time.
routes = {
"residential-us-1": "http://user-session-a1:pass@gw.res.example:8080",
"residential-de-1": "http://user-session-b2:pass@gw.res.example:8080",
"datacenter-1": "http://user-session-c3:pass@gw.dc.example:8080",
"mobile-us-1": "http://user-session-d4:pass@gw.mob.example:8080",
}
audit(routes)
What you're looking for, concretely:
-
http_versionmismatch (baselineHTTP/2, routeHTTP/1.1) — the route downgrades. Any h2 fingerprint work you do is dead weight on this route. If it's a tier you rely on for hard targets, either escalate with the provider or route around it. -
ja4mismatch withhttp_versionmatching — the tunnel survived but something rewrote the ClientHello. This is the MITM case. Quarantine the route for fingerprint-sensitive targets. -
h2_hashmismatch with everything else matching — frame-level mangling. Rarest, nastiest, and worth reporting because it usually points at a specific middlebox, not a general design.
Make it continuous, not a one-off
A one-time audit ages badly — providers re-deploy gateway software, add regions, and change upstream carriers without telling you. Two production practices fix that:
Route certification at pool-build time. Every new exit region or gateway hostname gets probed before it enters the pool. The report becomes a column in your pool metadata: h2_ok: bool, tls_ok: bool. Fingerprint-sensitive tasks then filter the pool: WHERE h2_ok AND tls_ok. This turns a debugging session into a routing decision.
Canary probes on a schedule. Every N minutes, one cheap request per tier through the echo endpoint, results into your metrics system. Alert on state change, not on absolute value — you care that a route flipped from transparent to rewriting, which usually means an upstream change that will start costing you captures within hours.
def certify_route(proxy: str, samples: int = 5) -> dict:
"""Probe a route repeatedly; a transparent route is stable AND equal
to baseline. Instability is its own failure (load balancers that
split across mixed gateway software)."""
results = [probe("sample", proxy) for _ in range(samples)]
versions = {r.http_version for r in results}
ja4s = {r.ja4 for r in results}
return {
"stable": len(versions) == 1 and len(ja4s) == 1,
"http_version": versions.pop() if len(versions) == 1 else "MIXED",
"samples_ok": sum(r.ok for r in results),
}
Note the samples=5 and the stability check — I've seen a gateway cluster where roughly one node in four ran an older stack, so a single probe passed 75% of the time. Flaky transparency is worse than consistent rewriting, because your failures look random.
Why this matters more than people expect
The practical payoff is diagnosis speed. When a hard target suddenly starts challenging your traffic, the first question should be "what changed on the path?", not "what changed in my code?" — because nine times out of ten, nothing changed in your code. A route certification table turns a multi-day mystery into a one-line diff: mobile-us-1 flipped to h2_ok=false at 03:14.
And there's a quieter benefit: knowing your tiers' transparency properties lets you spend money correctly. If your datacenter tier passes fingerprint integrity but fails IP reputation on hard targets, and your mobile tier has great reputation but rewrites the handshake, those are different products for different jobs — and you only know that if you measured both axes.
Fingerprint work has a reputation as a client-side discipline. It isn't, fully. Your fingerprint is a contract between your client and the target server, and every hop in between is a party that can breach it. Audit the route, certify the pool, and stop debugging your own code for other people's rewrites.
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)