Fingerprint Coherence: Why a Perfect JA3 Still Gets You Blocked
I want to walk you through the most instructive failure I've had in years of building scrapers, because the lesson generalizes to almost every blocking problem you'll hit.
The day a better fingerprint made things worse
A price-monitoring project of mine was getting soft-blocked on maybe 30% of requests. The stack was crude: python-requests through a rotating proxy pool. Classic move, classic problem — requests speaks TLS like a Python script and HTTP/1.1 like it's 2013, so the JA3 screams "not a browser" from the first ClientHello.
I did the "right" thing. I swapped requests for curl_cffi with impersonate="chrome124". curl_cffi replays Chrome's exact TLS handshake — the ClientHello extensions, the cipher order, the ALPN. My JA3 went from "python-requests 2.x" to byte-identical with a real Chrome 124.
The block rate jumped from 30% to 70%.
That's not a typo. It got worse, and it got worse because the JA3 got better. Before the change, my traffic looked like generic background noise. After the change, I had a Chrome TLS handshake followed by HTTP headers in python-requests order, over HTTP/1.1, with a lowercase header casing scheme no Chrome build has ever produced. No real browser emits that combination. The anti-bot system didn't score my fingerprint; it scored the distance between my layers. I'd gone from "unremarkable garbage" to "actively impersonating a browser, badly."
That's the thesis of this post: modern anti-bot systems don't score fingerprints individually. They score the coherence of your whole stack — TLS, HTTP/2 behavior, header presentation, and network context — against each other. A Chrome JA3 with Firefox header order is a stronger bot signal than either fingerprint alone, because each individual fingerprint at least exists in nature, while the combination doesn't.
The layers that get correlated
Here's the stack an anti-bot sees on a single request, and what it cross-checks:
| Layer | What's captured | What coherence breaks it |
|---|---|---|
| TLS | JA3 / JA4 from the ClientHello | Chrome handshake + non-Chrome everything else |
| HTTP/2 | SETTINGS frame values, pseudo-header order (:method, :authority, :scheme, :path), PRIORITY frames, WINDOW_UPDATE |
Chrome handshake + h2 settings from a different browser build |
| Header casing & order | Exact sequence and capitalization of headers | Chrome TLS + lowercase/sorted headers (a requests artifact) |
| Client hints & UA family | User-Agent, sec-ch-ua, Accept-Language
|
UA claims Chrome 124 but sec-ch-ua claims 116 |
| Network | Egress IP, ASN, geo vs. claimed identity | "Chrome user with de-DE Accept-Language" exiting a Virginia datacenter |
| Behavioral | Request cadence, timing between navigation and asset fetches | Hitting 40 product pages at perfect 5-second intervals |
The key mental shift: none of these is a pass/fail test individually. A datacenter IP is suspicious but survivable. A weird header order is suspicious but survivable. The product of the suspicions is what kills you. These systems are correlation engines, and every layer you leave inconsistent multiplies your score.
The header casing one is chronically underestimated. Chrome sends accept: text/html,... in lowercase and a specific order; Firefox capitalizes differently; requests lowercases everything and orders by insertion. Header order and casing are delivered to the server as-is, and they're trivially cheap to check. If your TLS says Chrome but your headers say Python, you've handed the detector a signed confession.
Auditing your own coherence
You can't fix what you can't see. The single most useful endpoint I know for this is https://tls.peet.ws/api/all — it echoes back your TLS fingerprint (including ja4), your HTTP/2 fingerprint (http2.akamai_fingerprint plus the actual http2.sent_frames), and your header order, all in one JSON response.
Here's a coherence auditor I use. The idea: save a reference profile by visiting the endpoint from a real browser whose persona you want to copy, then run your scraper against the same endpoint and diff layer by layer — TLS, HTTP/2 settings, header order, header casing — reporting mismatches per layer instead of one opaque verdict:
"""
fingerprint_audit.py — score your scraper's cross-layer coherence
against a reference profile captured from a real browser.
1. Open https://tls.peet.ws/api/all in your target browser,
save the JSON as reference.json next to this script.
2. Run: python fingerprint_audit.py
"""
import json
import pathlib
from curl_cffi import requests
ENDPOINT = "https://tls.peet.ws/api/all"
REFERENCE = pathlib.Path("reference.json")
def load_reference():
if not REFERENCE.exists():
raise SystemExit(
"reference.json not found — visit " + ENDPOINT +
" in your target browser and save the response first."
)
return json.loads(REFERENCE.read_text(encoding="utf-8"))
def fetch_profile():
r = requests.get(ENDPOINT, impersonate="chrome124", timeout=30)
r.raise_for_status()
return r.json()
def header_signature(profile, http_version):
"""Ordered (name, casing) list as actually sent on the wire."""
if http_version == "HTTP/2":
frames = profile.get("http2", {}).get("sent_frames", [])
headers_frame = next(
(f for f in frames if f.get("frame_type") == "HEADERS"), None
)
if headers_frame:
return [(h["name"], h["name"]) for h in headers_frame["headers"]
if not h["name"].startswith(":")]
return [
(n, n) for n in profile.get("http1", {}).get("header_order", [])
]
def h2_settings_signature(profile):
"""SETTINGS frame as (name, value) tuples, order-sensitive."""
frames = profile.get("http2", {}).get("sent_frames", [])
settings = next(
(f for f in frames if f.get("frame_type") == "SETTINGS"), {}
)
return [(s["name"], s["value"]) for s in settings.get("settings", [])]
def diff_layer(name, mine, ref):
if mine == ref:
print(f" [OK] {name}: matches reference ({len(mine)} items)")
return 0
print(f" [FAIL] {name}: MISMATCH")
mine_set, ref_set = set(map(str, mine)), set(map(str, ref))
for extra in sorted(mine_set - ref_set)[:5]:
print(f" yours only: {extra}")
for missing in sorted(ref_set - mine_set)[:5]:
print(f" ref only: {missing}")
if mine_set == ref_set and mine != ref:
print(" (same items, different ORDER — still a mismatch)")
return 1
def main():
ref = load_reference()
mine = fetch_profile()
failures = 0
print("=== Layer 1: TLS ===")
failures += diff_layer(
"ja4", mine.get("tls", {}).get("ja4", "?"),
ref.get("tls", {}).get("ja4", "?")
)
print("=== Layer 2: HTTP/2 ===")
if "http2" not in mine or "http2" not in ref:
print(" [FAIL] one side is not HTTP/2 — that alone is a signal")
failures += 1
else:
failures += diff_layer(
"akamai_fingerprint",
mine["http2"].get("akamai_fingerprint", []),
ref["http2"].get("akamai_fingerprint", []),
)
failures += diff_layer(
"SETTINGS frame",
h2_settings_signature(mine), h2_settings_signature(ref),
)
print("=== Layer 3: headers ===")
my_ver = mine.get("http_version", "")
ref_ver = ref.get("http_version", "")
failures += diff_layer(
"http version", my_ver, ref_ver
)
if my_ver == ref_ver:
failures += diff_layer(
"header order & casing",
header_signature(mine, my_ver),
header_signature(ref, ref_ver),
)
print("=== Layer 4: client identity ===")
failures += diff_layer(
"user-agent", mine.get("user_agent", "?"), ref.get("user_agent", "?")
)
total = 4
print(f"\nScore: {total - failures}/{total} coherent layers")
if failures:
print("Fix the FAILs top-down — TLS and HTTP/2 are the loudest signals.")
if __name__ == "__main__":
main()
A few notes from running this in anger. First, curl_cffi's chrome124 profile nails ja4 and the akamai_fingerprint (that's its whole job), but the headers you pass are entirely yours — feed it requests-style headers and the audit fails Layer 3 every time. Second, watch the "same items, different ORDER" line: header order is part of the fingerprint, and most HTTP libraries silently normalize it away. Third, if the reference browser negotiated HTTP/2 and your client fell back to HTTP/1.1, that alone is a strong incoherence — version negotiation is driven by ALPN, which is part of your TLS layer.
Re-run the audit whenever you bump the impersonate version or touch header code. A Chrome persona drifts out of coherence with real Chrome within a few release cycles — sec-ch-ua and SETTINGS values change quietly between builds.
Fixing it: one persona, end to end
The fix is conceptually boring and operationally disciplined: pick one browser persona and express it at every layer.
-
TLS:
impersonate="chrome124"— one line, done. - HTTP/2: curl_cffi's impersonation covers SETTINGS and pseudo-header order for the same Chrome build. The failure mode is mixing builds: don't grab an old header set from a blog post captured on Chrome 110 while impersonating Chrome 124. Match the build.
-
Headers: copy the exact header set, order, and casing from the reference capture — the library handles pseudo-headers, but
sec-fetch-*,sec-ch-ua*,accept-languageare on you. The auditor's reference file is your source of truth. - Persona stability per session: a session presenting as Chrome 124 on request 1 and Chrome 116 on request 3 is incoherent with itself. Pin the persona, and pin it to the proxy IP — cookie jar, persona, and IP should travel as one unit.
- Never mix libraries mid-session. The classic self-own: fetch the initial page in Playwright, then replay cookies with curl_cffi for API calls. The TLS layer changes mid-session, and session tokens that migrate across fingerprint contexts get flagged almost immediately.
On tooling trade-offs: with Playwright you get cross-layer coherence for free — it is Chrome, so TLS, HTTP/2, and headers all agree — but you pay rendering cost. With curl_cffi you get TLS/h2 curated and must curate headers yourself. I use both: Playwright for targets that check behavioral signals, curl_cffi with a curated persona for high-volume API work.
Network coherence
The last layer is the one no amount of fingerprint curation fixes: your egress IP has to make sense next to your claimed identity. An IP whose ASN is a hosting provider, sitting in Virginia, presenting Accept-Language: de-DE and a German persona, is incoherent in a way that survives every other fix. Anti-bot systems weight ASN reputation heavily, and no browser persona overrides "this IP belongs to a datacenter."
This is the layer where residential proxies with geo-targeting actually earn their keep — the point isn't just "residential IPs are trusted," it's that you can align the IP's geography with your persona's language and locale, so the network layer agrees with the identity layer. If your persona claims German Chrome on Windows, exit through a German residential IP. Coherence, again, not just quality.
Wrapping up
The uncomfortable takeaway from my 30%-to-70% episode: fixing one layer while ignoring the others makes things worse, not better, because you stop looking like noise and start looking like a deliberate impersonation with tells. Anti-bot systems are correlation engines; your job is to present a stack where every layer agrees with every other layer, and keeps agreeing over time.
So: capture a reference profile from a real browser, run an audit like the one above before you ship, pin one persona end to end per session, and make your egress network agree with your claimed identity. Consistency across layers is the signal. Be coherent or be loud — the worst place to be is almost-coherent.
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)