What Is JA4 TLS Fingerprinting, and What Can It Actually Tell You?
A JA4 fingerprint describes how a client speaks TLS, before it sends a single header. Here is how it is built, why it replaced JA3, what it catches, and where it stops being evidence.
The first thing a client sends is not a header. It is a TLS client hello: a list of cipher suites it supports, the extensions it wants, the protocol versions it will accept, and the application protocol it hopes to speak. All of it arrives in the clear, before any request line, before any User-Agent. A JA4 fingerprint is a compact summary of that message, and it is interesting for one reason: the client did not choose it as a claim about itself. It fell out of whatever TLS library the client was built on.
That makes it a different kind of signal from everything in the HTTP layer. A User-Agent is a sentence the client writes. A JA4 fingerprint is closer to an accent.
Reading the string
A JA4 value has three parts separated by underscores:
t13d1516h2_8daaf6152771_b186095e22b6
The first part is readable without any lookup. t means TCP, and q in that position would
mean QUIC. 13 is TLS 1.3. d says a SNI was present, so the client asked for a domain rather
than connecting to a bare IP. 15 is the number of cipher suites offered and 16 the number of
extensions. h2 is the ALPN value, so this client asked for HTTP/2.
The second part is a truncated hash of the cipher list, sorted. The third covers the extension list and the signature algorithms, also sorted. GREASE values, the deliberate junk entries browsers insert to keep middleboxes honest, are stripped before hashing.
Sorting sounds like a detail. It is the entire reason the format exists.
Why JA3 stopped working
JA3, the 2017 predecessor, hashed the same fields in wire order. That was fine while TLS stacks were predictable. Then Chrome shipped extension permutation in early 2023, shuffling the order of its client hello extensions on every connection, and a single Chrome install started producing a different JA3 hash per request. Rules built on JA3 allowlists went from precise to useless in a release cycle.
JA4 sorts before hashing, so reordering changes nothing. It also keeps the human-readable prefix outside the hash, which means you can tell a QUIC connection from a TCP one, or spot a client offering three cipher suites, without a database of known values. The rest of the JA4+ family covers other layers: JA4H for HTTP headers, JA4T for TCP options, JA4S for the server's response. Most detection work uses the TLS one.
Check what the connection's origin says about it
What it catches
The honest version: JA4 is very good at catching clients that never tried to blend in.
A Python script using requests produces a client hello that no browser produces. Go's default
net/http transport has its own recognisable shape. So does curl, so does an old OpenSSL build,
so does the HTTP client inside most SDKs. None of these are hiding, they simply are what they
are, and the fingerprint says so even when the request announces itself as Chrome 141 on macOS.
That mismatch is the useful part. On its own, a fingerprint belonging to a Go HTTP client tells you nothing bad. Paired with a User-Agent claiming to be a browser, it tells you the client is describing itself inaccurately, and the reasons for doing that are mostly not benign.
The same logic applies to headless browsers, though more weakly. A real Chrome build and a
Playwright-driven Chrome share a TLS stack, so their JA4 values match. What separates them is
elsewhere: the webdriver flag, missing browser surfaces, request timing, and whether the client
ever fetches the stylesheets and fonts a browser would. This is why the fingerprint is one input
rather than the verdict, a point that also came up in
the best signals for bot detection.
Where it stops being evidence
Two limits matter, and both get glossed over in vendor copy.
A fingerprint is a cohort, not an identity. Every Chrome 141 on Windows sends effectively the same client hello. That is millions of people sharing one string. You can say "this connection came from something shaped like Chrome 141", and you cannot say anything about who. Treat a JA4 value as an identifier for a visitor and you will be wrong constantly, in both directions.
Fingerprints are copyable. uTLS lets a Go program emit a client hello byte-identical to a chosen Chrome version. curl-impersonate does the same for curl. Anyone motivated enough to read one blog post can make their scraper present a perfectly ordinary browser fingerprint. What that costs is effort, and effort is a real filter: the difference between a script that bothered and one that did not is most of the automation on the internet. But a determined operator gets past this signal, so anything that depends on JA4 alone will let them through.
There is a third, quieter problem. Corporate TLS-inspecting proxies rewrite the client hello, so every employee behind one shares the proxy's fingerprint rather than their browser's. Block on a fingerprint and you can lose an entire company at once.
Using it next to the other signals
The pattern that holds up is stacking, not ranking. Each signal answers a different question:
| Signal | Question it answers | Fails when |
|---|---|---|
| JA4 | What TLS stack is this? | The client uses uTLS, or sits behind an inspecting proxy |
| IP origin | Where does this connect from? | The operator rents residential addresses |
| Headless markers | Is a browser being driven? | The tooling patches its own tells |
| Header consistency | Do the claims agree with each other? | The request was hand-built carefully |
| Reverse DNS + ASN | Is this crawler who it says it is? | The operator publishes nothing to check |
Read down the "fails when" column and the point becomes obvious: the evasions are different for each row. Beating one is easy, beating all five at once is a project. A datacenter IP plus a Go fingerprint plus a Chrome User-Agent is not ambiguous. A residential IP with a real Chrome fingerprint and no headless tells is probably a person, and should be treated like one.
This is the logic Agentscan runs on. One POST returns a class, human,
known_bot, ai_agent or malicious_automation, with a confidence value and the signals behind
it, JA4 among them. Cached verdicts come back in under 50ms, which is what lets the check sit in
a middleware hop instead of a log review the next morning.
A workable policy
- Get the fingerprint from where TLS terminates. Application code never sees the client hello. It comes from your CDN, your load balancer, or a detection API sitting in front.
- Score the mismatch, not the fingerprint. The signal is "TLS stack disagrees with claimed browser", not "this hash is bad". Maintaining a blocklist of hashes ages badly, since every browser release mints new ones.
- Never let it act alone. Pair it with origin at minimum. On its own it will catch a corporate proxy and miss a competent scraper, which is the wrong result twice.
- Expect the values to churn. Browser updates change cipher and extension sets. Anything you pin today needs a review schedule, or it quietly starts flagging real users.
- Log it either way. Even when you do not act on it, storing the JA4 with each request makes the next incident readable. Finding that 40,000 requests shared one unusual fingerprint is a much faster start than reading raw logs.
Bottom line
JA4 tells you how a client speaks TLS, which is harder to fake casually than any header and easier to fake deliberately than most vendors admit. It is a strong signal about the shape of the software on the other end, a weak signal about intent, and no signal at all about identity. Use it to catch the automation that never tried to look human, stack it with IP origin and headless markers for the automation that did, and do not build a blocking rule that rests on it alone.
FAQ