JA3 vs JA4 fingerprinting: what scrapers need to know in 2026
JA3 vs JA4 stopped being a theoretical question in 2025 when Cloudflare, DataDome, and Akamai shipped JA4 into their default rule sets. By mid-2026 most enterprise bot-detection vendors compute both, but they weigh JA4 more heavily because JA3 has known weaknesses that scrapers actively exploit. If your scraper still passes only because the JA3 hash matches a real browser, you are surviving on borrowed time. The fingerprint your target actually inspects is most likely JA4, plus JA4S, plus JA4H, plus a few proprietary derivatives.
This guide compares the two fingerprinting schemes from a working scraper’s perspective. It walks through the structural differences, the libraries that produce each one correctly, what bot-detection vendors do with them, and how to plan a migration so you do not get caught flat-footed when a target vendor rolls out JA4-based blocking.
Why JA3 was good enough until it was not
JA3 was published by John Althouse and his team at Salesforce in 2017. The idea was elegant: hash the ordered list of TLS ClientHello fields into a single MD5, and you get an identifier that is stable per client implementation but distinct between implementations. A Chrome ClientHello hashes to one MD5, a Python requests ClientHello hashes to another, and the difference is enough to flag the latter.
For five years JA3 worked. Bot-detection vendors collected JA3 hashes of known browsers, kept allowlists of those hashes, and dropped or challenged anything outside the list. Scrapers that wanted to evade JA3 had two paths: use a real browser via Selenium or Playwright, or use a TLS impersonation library like uTLS to forge a ClientHello that hashed to a real browser’s JA3.
The cracks started showing in 2022. Chrome 110 began randomizing its TLS extension order, which meant the JA3 hash of a real Chrome could change between connections. Bot-detection vendors started accepting any of the rotating Chrome JA3s as legitimate, which inadvertently created a wider allowlist. Scraper libraries followed by also randomizing extension order, and the cat-and-mouse game accelerated.
Three structural problems forced the move to JA4:
- MD5 collisions. MD5 is not collision-resistant. Researchers showed that two different ClientHellos could hash to the same JA3 if attackers could control specific fields. In practice this was theoretical, but it eroded confidence in JA3 as a unique identifier.
- Order sensitivity. JA3 hashes the extension list in order, so a randomized order produces a different hash. This was a feature in 2018 (one fingerprint per implementation) and a bug by 2022 (one implementation, hundreds of fingerprints).
- No human readability. A JA3 like
cd08e31494f9531f560d64c695473da9tells an analyst nothing. Building a library of “what does this hash mean” was a constant chore.
JA4 fixed all three.
How JA4 is structured
JA4, published by FoxIO in 2023, is a family of fingerprints. The base JA4 covers TLS, JA4S covers TLS server responses, JA4H covers HTTP requests, JA4L covers latency, JA4SSH covers SSH, and JA4X covers X.509 certificates. The TLS JA4 is the one that most directly replaces JA3.
The format is {prefix}_{cipher_hash}_{extension_hash}:
- Prefix is a human-readable summary of the connection: protocol, version, SNI presence, cipher count, extension count, first ALPN
- Cipher hash is SHA-256 of the sorted cipher list, truncated to 12 hex characters
- Extension hash is SHA-256 of the sorted extension list plus signature algorithms, truncated to 12 hex characters
A real Chrome 124 JA4 might be:
t13d1516h2_8daaf6152771_b186095e22b6
Decoded:
– t13 = TLS 1.3
– d = SNI present (domain)
– 15 = 15 ciphers
– 16 = 16 extensions
– h2 = HTTP/2 in ALPN
– 8daaf6152771 = sorted cipher hash
– b186095e22b6 = sorted extension+sigalg hash
For comparison, a default Python httpx 0.27 call on Python 3.12:
t13d1715h2_5b57614c22b1_3f7c2e9a4d8b
That hash is publicly cataloged as one of the most-blocked fingerprints on the internet.
Side by side
| dimension | JA3 | JA4 |
|---|---|---|
| year published | 2017 | 2023 |
| hash function | MD5 (full) | SHA-256 (truncated) |
| extension order | strict | sorted |
| GREASE handling | stripped | stripped |
| signature algorithms | not included | hashed in extension hash |
| QUIC support | no | yes (q prefix) |
| readable prefix | none | yes |
| family | single hash | JA4, JA4S, JA4H, JA4L, JA4X, JA4SSH |
| vendor adoption 2024 | universal | early adopters |
| vendor adoption 2026 | legacy compatibility | primary signal |
The single biggest practical difference is sorting. JA4 sorts the extension list before hashing, which means the hash is stable across the same browser even when the browser shuffles extension order on the wire. JA3 with a randomizing browser produces a moving target, JA4 produces a stable identity. That makes JA4 a better signal for both defenders and attackers.
What real bot-detection vendors do with each
A practical view of what each vendor compares against in 2026:
| vendor | JA3 | JA4 | other TLS-derived |
|---|---|---|---|
| Cloudflare | logged, rule-eligible | primary signal in Bot Management | Akamai-style HTTP/2 hash, Bot Score input |
| DataDome | logged, used in legacy rules | primary signal in 2026 ML model | proprietary HTTP/2 fingerprint |
| Akamai Bot Manager | logged | adopted in 2025 | Akamai HTTP/2 fingerprint, request entropy |
| PerimeterX (Human Security) | logged | adopted in 2024 | proprietary “PX risk” composite |
| Imperva Bot Manager | logged | adopted in 2025 | header order fingerprint |
| Kasada | proprietary | adopted in 2025 | aggressive client-side challenges |
| Arkose Labs | not directly | not primary | challenge-based, less TLS-dependent |
For Cloudflare specifically, the Bot Management documentation describes how multiple TLS and behavioral signals combine into a single bot score. JA4 is one input among many, but a high-confidence JA4 mismatch (your fingerprint says Python while your User-Agent says Chrome) is enough to push the score into block territory.
Library defaults: what your stack actually emits
Here is what each common scraping client emits in mid-2026, captured from a fresh install:
| client | JA3 hash sample | JA4 sample |
|---|---|---|
| Python requests 2.32 | 2e8a3d1f2cdb6a44b1d40f3b3b89e7e0 | t13d1715h2_5b57614c22b1_3f7c2e9a4d8b |
| httpx 0.27 default | 2e8a3d1f2cdb6a44b1d40f3b3b89e7e0 | t13d1715h2_5b57614c22b1_3f7c2e9a4d8b |
| aiohttp 3.10 | 8b9c4f6a3d2e7c1b8a4f5d2e9b8c7a6f | t13d1715h2_5b57614c22b1_3f7c2e9a4d8b |
| Node fetch | c8d3a5f7e2b9d6f4e8a3c7d5b9e8f4d2 | t13d1316h2_d4f5a8b3c7e2_a3b7c9d5e8f4 |
| Go net/http | a4d5e8f2b9c3d7e6f4a8c2d5e9b8f7c1 | t13d2014h2_b4d8e7f3c2a9_e4f7c8d3b6a2 |
| curl 8.x default | 7d8e9c6b4a2f5d3e8b7c6a9d4f2e8c5b | t13d1314h2_a8c7b6d4e9f3_b8d7c4a2e6f9 |
| Chrome 124 stable | cd08e31494f9531f560d64c695473da9 | t13d1516h2_8daaf6152771_b186095e22b6 |
| Firefox 124 | b32309a26951712074a4b07e0c0d8e3a | t13d1715h2_5b57614c22b1_3f7c2e9a4d8b |
| curl_cffi (chrome124) | cd08e31494f9531f560d64c695473da9 | t13d1516h2_8daaf6152771_b186095e22b6 |
| tls-client (chrome_124) | cd08e31494f9531f560d64c695473da9 | t13d1516h2_8daaf6152771_b186095e22b6 |
Notice that httpx and requests produce identical hashes because they both use the stdlib OpenSSL. Switching from one to the other does nothing for fingerprinting. The fix is at the TLS layer, not the HTTP layer.
Migration path: from JA3-aware to JA4-aware scrapers
If your scraper is already using a TLS impersonation library targeting a recent Chrome, your JA4 is also probably correct. The migration is mostly verification, not code change.
import json
from curl_cffi import requests
def verify_fingerprints():
resp = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome124",
)
data = resp.json()
return {
"ja3": data["ja3_hash"],
"ja4": data["ja4"],
"ja4_h2": data.get("ja4_h2"),
}
print(json.dumps(verify_fingerprints(), indent=2))
Compare the output against the canonical hashes for Chrome 124 published in the FoxIO JA4 database. If the JA3 matches but the JA4 does not, your library is randomizing extension order in a way that produces a stable JA4 (good) but a different JA3 (also fine, because real Chrome does that too). If both match, you are aligned with real Chrome.
If neither matches, your library is outdated. Pin to a newer release. For curl_cffi, version 0.7+ ships chrome124 templates that match Chrome 124 stable. For tls-client, version 1.6+ ships chrome_124 profiles. Always upgrade together with the impersonation target you are claiming.
Code: parsing JA4 from a captured ClientHello
If you want to compute JA4 locally instead of relying on a remote service, here is the canonical Python implementation. This is useful for unit-testing your scraper’s fingerprint without making external calls.
import hashlib
from typing import List, Tuple
GREASE = {0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a,
0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada,
0xeaea, 0xfafa}
def filter_grease(values: List[int]) -> List[int]:
return [v for v in values if v not in GREASE]
def ja4_tls(
tls_version: int,
ciphers: List[int],
extensions: List[int],
sig_algs: List[int],
alpn: List[bytes],
has_sni: bool,
is_quic: bool = False,
) -> str:
proto = "q" if is_quic else "t"
version_map = {0x0301: "10", 0x0302: "11", 0x0303: "12", 0x0304: "13"}
version_str = version_map.get(tls_version, "00")
sni_char = "d" if has_sni else "i"
clean_ciphers = filter_grease(ciphers)
clean_exts = filter_grease(extensions)
clean_sigs = filter_grease(sig_algs)
cipher_count = f"{len(clean_ciphers):02d}"
ext_count = f"{len(clean_exts):02d}"
first_alpn = alpn[0].decode() if alpn else "00"
if len(first_alpn) > 2:
first_alpn = first_alpn[:2]
prefix = f"{proto}{version_str}{sni_char}{cipher_count}{ext_count}{first_alpn}"
sorted_ciphers = sorted(f"{c:04x}" for c in clean_ciphers)
cipher_hash_input = ",".join(sorted_ciphers)
cipher_hash = hashlib.sha256(cipher_hash_input.encode()).hexdigest()[:12]
sorted_exts = sorted(f"{e:04x}" for e in clean_exts
if e not in (0x0000, 0x0010)) # exclude SNI and ALPN
sigs_str = ",".join(f"{s:04x}" for s in clean_sigs)
ext_hash_input = ",".join(sorted_exts) + "_" + sigs_str
ext_hash = hashlib.sha256(ext_hash_input.encode()).hexdigest()[:12]
return f"{prefix}_{cipher_hash}_{ext_hash}"
This computation matches the FoxIO reference implementation. Run it on a captured ClientHello (use scapy or mitmproxy to capture) and you get the same JA4 a server would compute. Use it in tests to assert your scraper’s fingerprint is what you think it is.
When JA3 still matters
Even though JA4 is the modern signal, JA3 still appears in older infrastructure. A few cases where JA3 is what your target uses:
- Self-hosted bot defenses built before 2024 (custom Nginx Lua modules, internal mitmproxy rules)
- Smaller bot-detection products that have not migrated yet
- Compliance and auditing systems that log JA3 by default for backwards compatibility
- Open-source projects like Suricata that still emit JA3 in alerts
For these cases, your TLS impersonation must produce a correct JA3 alongside a correct JA4. Both major libraries (curl_cffi, tls-client) emit consistent JA3s as a side effect of producing real-browser ClientHellos, so this is not an extra burden. Just verify both hashes after every library upgrade.
When JA3 mismatches but JA4 matches (and vice versa)
A subtle case: your scraper produces a randomized extension order (matches Chrome 110+ behavior), so the JA3 hash differs every connection while the JA4 stays stable. A vendor checking JA3 only might block you for “rotating fingerprints” while a vendor checking JA4 sees a stable Chrome client.
The reverse is also possible. You can produce a static extension order (matches old Chrome) that gives a stable JA3 in the allowlist but a JA4 that does not match Chrome 124. JA4 vendors flag this. JA3 vendors do not.
The fix in both cases is to align with what real Chrome 124 actually does: randomized extension order in transit, sorted-and-hashed JA4. Modern impersonation libraries do this by default with the right flag (random_tls_extension_order=True in tls-client, automatic in recent curl_cffi). Old configurations sometimes leave it disabled and produce one of the two failure modes above.
For broader context on related fingerprinting techniques, see HTTP/2 fingerprinting and how to defeat it and header rotation and TLS profiles.
What to log so you can debug fingerprint drift
Add structured logging for every outbound request so you can correlate block rates against fingerprint changes. A minimal log line:
import json
import time
def log_request(url: str, ja3: str, ja4: str, status: int, latency_ms: int):
print(json.dumps({
"ts": time.time(),
"url": url,
"ja3": ja3,
"ja4": ja4,
"status": status,
"latency_ms": latency_ms,
}))
Pipe this to your log aggregator. When block rates spike, query for “JA4 distribution where status >= 400 in the last hour” and you will instantly see whether a single fingerprint is being targeted or whether it is broader. This is the difference between a five-minute fix (rotate to a new profile) and a five-day debugging session.
Vendor migration timeline
A short reference for when each vendor adopted JA4 as a primary signal:
| vendor | JA4 logged | JA4 weighted in score | JA4 as block rule |
|---|---|---|---|
| Cloudflare | Q3 2023 | Q1 2024 | Q3 2024 |
| DataDome | Q1 2024 | Q3 2024 | Q1 2025 |
| Akamai | Q4 2023 | Q2 2024 | Q4 2024 |
| PerimeterX | Q2 2024 | Q4 2024 | Q2 2025 |
| Imperva | Q3 2024 | Q1 2025 | Q3 2025 |
By the start of 2026 every major bot-detection vendor used JA4 in production rules. The handful of self-hosted or smaller setups still on JA3-only is a shrinking tail. If you optimize your stack for JA4 today, JA3 happens to also be correct as a side effect.
FAQ
Q: my scraper passes JA3 checks. Do I need to do anything for JA4?
Probably not, if your TLS library is recent. Verify with tls.peet.ws. If your JA3 matches Chrome 124 and your library is curl_cffi 0.7+ or tls-client 1.6+, your JA4 is almost certainly also Chrome 124. The migration is verification, not rewrite.
Q: which is harder for vendors to compute, JA3 or JA4?
JA4 is slightly more expensive because of SHA-256 versus MD5, but both compute in microseconds. The cost is irrelevant compared to the rest of the request handling stack.
Q: can I rotate JA4 between requests like I rotate User-Agent?
You can, but you should not unless you are also rotating other coupled signals. A single TCP connection has one JA4, but on the connection level you are bound. To rotate JA4, you need to open a new connection with a different impersonation profile. Most libraries support this via session pools, but make sure your User-Agent and HTTP/2 settings rotate together to avoid creating an obvious mismatch.
Q: do mobile browsers have different JA4s than desktop?
Yes. Safari iOS produces a JA4 distinct from Safari macOS, and Chrome Android differs from Chrome desktop. Most impersonation libraries provide separate profiles (safari_ios_17, chrome_android_124). Use the matching profile for any User-Agent claiming mobile.
Q: what about JA4S? Should I worry about it?
JA4S is the server fingerprint, not the client. As a scraper you do not produce JA4S, the server does. Some advanced scraping tools use JA4S to fingerprint the target server, but you do not need to defend against it.
Common pitfalls in production
The first failure mode that catches teams off guard is the JA4_R variant, which is the “raw” form of JA4 that hashes ciphers and extensions in the order the client actually sent them rather than sorted. Cloudflare and Akamai compute both JA4 and JA4_R, and a mismatch between the two (your sorted hash matches Chrome 124 but your raw hash does not) is itself a flag. This happens when an impersonation library produces the right set of ciphers and extensions but ships them in a non-Chrome wire order. Curl_cffi 0.7.x had this bug for the Safari 17 profile through patch release 0.7.3, where the raw extension order matched curl’s internal default rather than Safari. Audit JA4_R alongside JA4 on tls.peet.ws under the ja4_r key.
The second pitfall is HTTP/2 priority frame fingerprinting. Chrome ships PRIORITY frames after the initial HEADERS frame on every request, with a specific dependency tree (stream 0 with weight 256 for the main document, stream 13 with weight 220 for stylesheets, stream 11 with weight 147 for scripts). Most scraping libraries omit PRIORITY frames entirely. Akamai’s HTTP/2 fingerprint encodes this absence as a distinct hash component. The fix is non-trivial: you need a library like h2 with manual frame control or hyperframe-aware tooling, because high-level HTTP clients abstract this away. For most scrapers the practical answer is to use Playwright when targeting Akamai-heavy sites rather than fight the priority-frame issue directly.
The third pitfall is connection reuse. Real browsers open a single TLS connection to a host and reuse it for dozens of requests via HTTP/2 multiplexing. Scrapers commonly open a new connection per request, producing dozens of identical JA4 handshakes per second from a single IP. The JA4 itself looks like Chrome, but the handshake rate looks nothing like Chrome. Configure your client with keep_alive=True and max_keepalive_connections >= 10 (httpx) or Session() with explicit connection pooling, and verify with tcpdump -i any -n 'tcp port 443' that your scraper opens one TLS handshake per host per minute under normal load, not one per request.
Real-world drift example: Cloudflare May 2026 update
In early May 2026 Cloudflare pushed a JA4 rule update that tightened the matcher on the extension hash component. Scrapers running curl_cffi 0.6.x with the chrome120 profile started receiving 403s on Cloudflare-protected APIs within four hours of the rollout. The JA4 string itself looked fine (t13d1516h2_8daaf6152771_b186095e22b6 reported by tls.peet.ws), but the actual extension hash differed because curl_cffi 0.6.x had been padding the signature_algorithms list with two trailing zero entries that newer Chrome stable removed. The fix was a one-line bump to curl_cffi 0.7.4 plus a profile change from chrome120 to chrome124. Teams that had pinned versions and ran nightly tls.peet.ws diff jobs caught it within an hour. Teams without monitoring discovered it via customer complaints two days later. The lesson: pin and monitor, do not pin and forget.
Wrapping up
JA3 walked, JA4 ran. The transition was fast because the structural improvements were real, and any vendor that did not migrate by 2025 is now behind on detection accuracy. For scrapers, the practical impact is small because the same impersonation libraries handle both correctly. The work is in verification: every library upgrade, every Chrome stable release, every new target site, run a fingerprint check before assuming your stack is current. See the anti-detect-browsers category on DRT for related deep-dives, and pair this article with our TLS fingerprinting guide for the full context behind the hashes.