If you've ever tried to make an HTTP client "look like Chrome" to get past a WAF, you've probably copied a Chrome JA3 hash from somewhere, hard-coded it, verified it once against a reflector, and shipped. And then, weeks later, you got blocked anyway — with the exact fingerprint you "matched."
Here's what happened, and what to do about it.
Your TLS handshake talks before your HTTP does
Before a single byte of your HTTP request goes out, your TLS ClientHello is already on the wire: the version you offer, your cipher list, your extensions, your curves and signature algorithms. A CDN reads that and forms a fingerprint. If it says "Go stdlib" or "python-requests" instead of "Chrome," you can send the most perfect browser headers in the world and still eat a 403.
- JA3 (Salesforce, 2017): an MD5 over five ClientHello fields.
- JA4 (FoxIO, the modern successor): a structured, human-readable fingerprint — and it sorts the extension list before hashing.
That sorting is the whole story.
Chrome's JA3 is non-deterministic on purpose
Since Chrome 110 (2023), Chrome randomizes its TLS extension order on every connection — a deliberate anti-ossification measure. The side effect: Chrome's JA3 changes every connection. There is no "the" Chrome JA3. Hard-code one and you've pinned a single permutation real Chrome almost never reproduces — so you look less like Chrome.
Same client, three consecutive handshakes:
run 1: JA3 0a9d85cc... JA4 t13d1516h2_8daaf6152771_d8a2da3f94cd
run 2: JA3 b7ca61f3... JA4 t13d1516h2_8daaf6152771_d8a2da3f94cd
run 3: JA3 6910cc00... JA4 t13d1516h2_8daaf6152771_d8a2da3f94cd
JA3 is all over the place. JA4 is constant — the sort washes out the shuffle. Match JA4, not JA3. (Firefox/Safari/iOS don't shuffle the same way, so their JA3 stays stable — this is a Chrome/Chromium thing.)
The third fingerprint most people miss
Even with byte-perfect TLS, you get flagged by your HTTP/2 fingerprint (the "Akamai h2" string): SETTINGS values, WINDOW_UPDATE, stream priority, and pseudo-header order. Most HTTP libraries never think about frame order, so this is where "I set the right ciphers" setups quietly fail. The real checklist is three numbers: JA4 (TLS), Akamai h2 (HTTP/2), and matching headers — not just a User-Agent.
Matching them in practice
I got tired of re-solving this from both ends, so I put it in one small Go binary, fp (fpkit, MIT):
-
fp get <url> --profile chrome— fetch with a byte-exact Chrome ClientHello + HTTP/2 settings. No headless browser, no CGo. -
fp inspect— a reflector showing a caller's JA3/JA4/JA4H/Akamai-h2 with a plain-English verdict. -
fp check <url> --profile chrome— a CI guard that fails when your client stops looking like a browser. -
fp db ja4 <value>— reverse-look-up a JA4 against bundled browser + known-client fingerprints.
Try the reflector with no install:
curl --http2 https://fp.shshbshsnang.shop/api/all
or open https://fp.shshbshsnang.shop/ and it'll tell you whether you read as a browser or a bot.
Takeaways
- Don't hard-code a Chrome JA3 — it's non-deterministic by design. Match JA4.
- TLS isn't enough; match the HTTP/2 (Akamai) fingerprint too.
- Measure against a reflector, and re-measure — fingerprints drift with every browser release.
Honest limit: a careful spoofer can forge any JA4, so an inbound JA4 guard raises cost, it isn't a wall. If you've seen a profile drift, or a CDN keying off something I haven't documented, I'd like to hear it.
Top comments (0)