JA3 vs JA4 fingerprinting: what scrapers need to know in 2026

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:

  1. 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.
  2. 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).
  3. No human readability. A JA3 like cd08e31494f9531f560d64c695473da9 tells 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

dimensionJA3JA4
year published20172023
hash functionMD5 (full)SHA-256 (truncated)
extension orderstrictsorted
GREASE handlingstrippedstripped
signature algorithmsnot includedhashed in extension hash
QUIC supportnoyes (q prefix)
readable prefixnoneyes
familysingle hashJA4, JA4S, JA4H, JA4L, JA4X, JA4SSH
vendor adoption 2024universalearly adopters
vendor adoption 2026legacy compatibilityprimary 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:

vendorJA3JA4other TLS-derived
Cloudflarelogged, rule-eligibleprimary signal in Bot ManagementAkamai-style HTTP/2 hash, Bot Score input
DataDomelogged, used in legacy rulesprimary signal in 2026 ML modelproprietary HTTP/2 fingerprint
Akamai Bot Managerloggedadopted in 2025Akamai HTTP/2 fingerprint, request entropy
PerimeterX (Human Security)loggedadopted in 2024proprietary “PX risk” composite
Imperva Bot Managerloggedadopted in 2025header order fingerprint
Kasadaproprietaryadopted in 2025aggressive client-side challenges
Arkose Labsnot directlynot primarychallenge-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:

clientJA3 hash sampleJA4 sample
Python requests 2.322e8a3d1f2cdb6a44b1d40f3b3b89e7e0t13d1715h2_5b57614c22b1_3f7c2e9a4d8b
httpx 0.27 default2e8a3d1f2cdb6a44b1d40f3b3b89e7e0t13d1715h2_5b57614c22b1_3f7c2e9a4d8b
aiohttp 3.108b9c4f6a3d2e7c1b8a4f5d2e9b8c7a6ft13d1715h2_5b57614c22b1_3f7c2e9a4d8b
Node fetchc8d3a5f7e2b9d6f4e8a3c7d5b9e8f4d2t13d1316h2_d4f5a8b3c7e2_a3b7c9d5e8f4
Go net/httpa4d5e8f2b9c3d7e6f4a8c2d5e9b8f7c1t13d2014h2_b4d8e7f3c2a9_e4f7c8d3b6a2
curl 8.x default7d8e9c6b4a2f5d3e8b7c6a9d4f2e8c5bt13d1314h2_a8c7b6d4e9f3_b8d7c4a2e6f9
Chrome 124 stablecd08e31494f9531f560d64c695473da9t13d1516h2_8daaf6152771_b186095e22b6
Firefox 124b32309a26951712074a4b07e0c0d8e3at13d1715h2_5b57614c22b1_3f7c2e9a4d8b
curl_cffi (chrome124)cd08e31494f9531f560d64c695473da9t13d1516h2_8daaf6152771_b186095e22b6
tls-client (chrome_124)cd08e31494f9531f560d64c695473da9t13d1516h2_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:

vendorJA4 loggedJA4 weighted in scoreJA4 as block rule
CloudflareQ3 2023Q1 2024Q3 2024
DataDomeQ1 2024Q3 2024Q1 2025
AkamaiQ4 2023Q2 2024Q4 2024
PerimeterXQ2 2024Q4 2024Q2 2025
ImpervaQ3 2024Q1 2025Q3 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
message me on telegram

Resources

Proxy Signals Podcast
Operator-level insights on mobile proxies and access infrastructure.

Multi-Account Proxies: Setup, Types, Tools & Mistakes (2026)