← All articles

How JA3 and JA4 catch a scraper before the page even loads

5 min read detectiontlsnetwork

A script sends a request with headers claiming a current Chrome on Windows. The site returns a block in under 50 milliseconds, before a response body is generated and before any JavaScript runs. Nobody read the headers. The TLS handshake gave it away first.

The client hello carries an implementation signature

Before any application data moves, a TLS client sends a ClientHello: protocol version, a list of cipher suites, a list of extensions, supported elliptic curves, and signature algorithms, each in a specific order. That order is not configurable by the person making the request. It is baked into whichever TLS library assembled the packet. Chrome uses BoringSSL. Python's ssl module and requests sit on top of OpenSSL, ordered differently. Go's crypto/tls produces yet another order. curl links against whatever TLS library the build shipped with. Two clients can claim the exact same User-Agent string and still send handshakes that no real Chrome install would ever produce.

JA3 and JA4 hash exactly that order

JA3 concatenates TLS version, cipher suite list, extension list, elliptic curves, and elliptic curve point formats into one string, then MD5-hashes it. A given TLS library version, used the default way, produces the same JA3 hash every time, regardless of what headers get sent later. JA4 is the newer format from FoxIO, built to be more resistant to trivial randomization and easier to read at a glance (it encodes counts and a truncated hash of ciphers and extensions separately, rather than one opaque MD5). Both exist because the handshake is a far more reliable tell than a header, since nobody can send a fake handshake without actually implementing a different TLS stack.

Why the copied header does not survive contact

A commercial anti-bot vendor checks this before the request reaches application code. The claim is Chrome. The JA3 or JA4 hash says Python's urllib3, or Go, or a bare OpenSSL client. That contradiction is visible at the network edge, before routing, before any page logic runs, which is why blocks driven by this layer are instant and why adding more convincing headers never fixes it. The mismatch is one layer below the one you are editing.

HTTP/2 adds a second, independent signature

Past the handshake, HTTP/2 has its own fingerprint, sometimes discussed separately as an Akamai-style fingerprint. It includes the SETTINGS frame values a client sends first (header table size, initial window size, max concurrent streams), the order those settings are sent in, the order of pseudo-headers within a request (:method, :authority, :scheme, :path, ordered differently by Chrome than by curl or nghttp2), the ordering of regular headers within the frame, and whether PRIORITY frames accompany the stream. Like the TLS handshake, this is decided by the library, not by anything a script author writes into a headers dictionary.

Why this is an argument for driving a real browser

Neither of these layers can be patched by adding headers to a request. The TLS handshake and the HTTP/2 frame layout are produced by the network stack itself, underneath the code a scraper author controls. This is one of the underrated reasons a headless real browser beats a hand-rolled HTTP client stack even before a single line of JavaScript executes: the browser's actual BoringSSL and HTTP/2 implementation produces the correct signature by construction, because it is the correct implementation, not an approximation of one.

Where AntiBrow fits, and where it does not

AntiBrow does not modify the TLS stack. There is no code in the browser engine that reorders cipher suites or spoofs a JA3 or JA4 value. The benefit is narrower and more honest than that: every launched profile runs an unmodified Chromium network stack, so its handshake and HTTP/2 signature are whatever a genuine Chrome install produces, because it is a genuine Chrome install. This layer is not something the product engineers. It is a side effect of automating a browser instead of an HTTP client.

Where a plain HTTP client is the better choice

If the endpoint you are calling does not run TLS or HTTP/2 fingerprinting, which describes most internal APIs and most sites without a commercial anti-bot vendor in front of them, a plain requests or httpx call is faster, cheaper, and a fraction of the memory of a full browser process. Running a browser engine against a target that never inspects the handshake is wasted resource. For the middle case, open-source tools such as curl_cffi replicate a specific browser's JA3/JA4 and HTTP/2 order without the cost of a rendering engine, and are the right choice once you have confirmed this layer is actually what blocks you and you do not need JavaScript execution or persistent profile state. Reach for a full browser when you also need JS execution, canvas and WebGL coherence, or a profile that persists across sessions.

Verify rather than assume

Check your own client's signature before guessing that this layer is the problem. tls.peet.ws and ja3er.com will show the JA3/JA4 hash and HTTP/2 signature of whatever made the request, script or browser tab, and let you compare it against a known Chrome hash instead of assuming.

For where this layer sits inside the rest of an anti-bot decision, see how anti-bot systems work. For the layer above it, once JavaScript starts running, see what is browser fingerprinting.

Try it on the free tier.

Unlimited local profiles, no credit card. Check it against the detectors yourself.