Before a client sends a single byte of HTTP, it has already identified its software. The TLS handshake opens with a ClientHello message listing the cipher suites, extensions, and curves the client supports, in a specific order, and that combination is determined by the TLS library the client was built on. A Python script, a Go binary, and a real Chrome produce visibly different ClientHellos no matter what their User-Agent strings claim. TLS fingerprinting is the practice of hashing that handshake into a comparable signature, and JA3 and JA4 are the two formats you will encounter everywhere.

This is the network half of the story told in browser fingerprinting techniques. It pairs naturally with datacenter proxy detection and residential proxy detection, since the handshake survives both kinds of proxy.

What the ClientHello reveals

A TLS connection starts with the client declaring its capabilities: the protocol versions it accepts, the cipher suites it offers, the extensions it includes (SNI, ALPN, supported groups, signature algorithms, and a few dozen others), and the elliptic curves and point formats it supports. None of this is secret; it travels in cleartext before encryption is established.

The useful property is that clients do not choose these values at request time. They inherit them from their TLS library and its version. Chrome’s BoringSSL produces one pattern, Firefox’s NSS another, Python’s OpenSSL bindings another, Go’s crypto/tls another. The ClientHello is, in effect, a statement about what software is actually running, made before the application layer gets a chance to lie.

JA3: the original format

JA3, released by Salesforce researchers in 2017, reduced the ClientHello to an MD5 hash of five comma-separated fields: TLS version, cipher suites, extensions, elliptic curves, and point formats, each in offered order (github.com/salesforce/ja3). It became the de facto standard. Threat feeds published JA3 hashes of known malware and scraping libraries, and a server could match an incoming handshake against them in microseconds.

JA3 had a structural weakness: it hashed the extension list in order, and the order turned out to be mutable. Chrome shipped per-connection extension-order randomization on by default in Chrome 110 (February 2023) across desktop, Android, and WebView (but not iOS, which uses Apple’s network stack), specifically to stop servers from ossifying against a fixed Chrome handshake. TLS 1.3 permits this: RFC 8446 lets extensions appear in any order, with the single exception that pre_shared_key must come last. The change gave every Chrome connection a different JA3 hash, which destroyed JA3’s value for identifying Chrome and anything imitating it. Exact-match JA3 still works against static-stack clients like requests or Go, but the format is effectively legacy.

JA4: the current standard

JA4, from FoxIO in 2023, was designed after the randomization lesson (github.com/FoxIO-LLC/ja4). It abandons the opaque MD5 for a readable three-part string, JA4_a, JA4_b, and JA4_c, joined by underscores, and it sorts the cipher and extension lists before hashing so per-connection randomization no longer changes the fingerprint. A defender can compare two JA4 strings by eye and see which part differs.

t13d1516h2_8daaf6152771_e5627efa2ab1
JA4_aReadable prefix: transport, TLS version, SNI, counts, and ALPN.
JA4_bTruncated SHA-256 of the sorted cipher-suite list.
JA4_cTruncated SHA-256 of the sorted extensions and signature algorithms.
tTransportTCP (q = QUIC)
13TLS versionTLS 1.3
dSNIDomain present (i = none)
15Ciphers15 suites offered
16Extensions16 present
h2ALPNFirst value is h2
A representative JA4 for a recent desktop Chrome. The JA4_a prefix is human-readable; the two hashes fold long, sorted lists into twelve hex characters each. The hash halves shift whenever Chrome changes its cipher or extension set; the prefix barely moves.

The first part, JA4_a, is ten characters anyone can read without tooling. The example above decodes to: TCP transport, TLS 1.3, SNI present, fifteen cipher suites, sixteen extensions, and h2 as the first ALPN value. Two clients that disagree here, one offering TLS 1.2 with twelve ciphers against another offering TLS 1.3 with fifteen, are visibly different software before you reach the hashes.

JA4_b and JA4_c carry the rest. Each is the first twelve hex characters of a SHA-256: JA4_b over the cipher-suite list, JA4_c over the extensions and signature algorithms. The decisive choice is that both lists are sorted before hashing. JA3 hashed them in wire order, which is exactly what Chrome’s per-connection shuffle scrambles; sorting throws that ordering away on purpose, so a randomized Chrome and an unshuffled one collapse to the same JA4. GREASE values, the deliberately random placeholder code points browsers inject, are stripped for the same reason. What survives is the set of capabilities the client supports, not the accident of how it ordered them on this connection.

JA4 is also a family. JA4H fingerprints HTTP headers, JA4T fingerprints TCP options, JA4S and JA4X cover the server side and certificates. The pieces compose: a session whose JA4 matches Chrome but whose JA4T matches a Linux server has already contradicted itself, which is the same joint-distribution logic used across the rest of the fingerprinting stack. Most edge providers now expose JA3 and JA4 values directly to application rules.

The handshake keeps moving

A TLS fingerprint is not a permanent identifier for a browser. It drifts as the browser’s cryptography evolves, and two recent shifts matter for anyone matching against stored fingerprints.

Chrome’s rollout of post-quantum key exchange visibly reshaped the ClientHello. The hybrid key agreement X25519Kyber768 shipped in Chrome 116 (2023) and became default on desktop in Chrome 124 (April 2024), adding more than a kilobyte to the ClientHello, because the post-quantum key share runs past 1,200 bytes against 32 for classical X25519 (Chromium blog). Chrome 131 (November 2024) then replaced the draft Kyber construction with the standardized X25519MLKEM768, changing the key-share code point again. Each of those steps moved the fingerprint of every up-to-date Chrome, which is the practical reason a JA3/JA4 allow-list keyed to last year’s Chrome will start flagging this year’s. The size jump also broke real middleboxes that could not handle the larger handshake, which is its own detectable behavior.

Encrypted Client Hello is the other shift, published as RFC 9849 in March 2026. ECH encrypts a private inner ClientHello (including the server name and ALPN) under the server’s public key and carries it inside a public outer ClientHello. It does not hide the outer handshake: the cleartext extension code points and their order, the message lengths, and the presence of the encrypted_client_hello extension itself all stay on the wire (RFC 9849, §10.10.4). JA3/JA4-style fingerprinting of the outer ClientHello still works, and because ECH is unevenly deployed, supporting it is itself a client-distinguishing signal.

What TLS fingerprinting catches

In rough order of value:

  • HTTP libraries claiming to be browsers. The bread-and-butter case. requests, aiohttp, Go’s net/http, Java’s HttpClient, and curl each have distinctive handshakes. A ClientHello from Go carrying a Chrome User-Agent is a finished investigation. This single check removes the bottom tier of scraping and credential-stuffing traffic at the network edge, before any JavaScript runs.
  • Mismatched browser claims. Chrome on Windows, Chrome on Android, Safari on iOS, and Firefox on Linux all differ. A session claiming iPhone Safari with a BoringSSL handshake is lying about something.
  • Proxy and relay artifacts. The handshake the server sees belongs to whatever client actually opened the connection. Traffic tunneled through a residential exit still presents the automation stack’s TLS fingerprint unless the operator has specifically addressed it, and many do not.
  • Known-tool signatures. Stealth stacks, attack frameworks, and stress tools have published fingerprints, so a feed match can attribute traffic to a named tool.

Operators respond with impersonation libraries: curl-impersonate and tls-client replicate real-browser ClientHellos with reasonable fidelity (github.com/lwthiker/curl-impersonate). Impersonation raises the bar rather than ending the game, because the handshake has to stay coherent with everything above it: the HTTP/2 SETTINGS frame, header order, ALPN negotiation, JA4T at the TCP layer, and eventually the JavaScript environment. Each layer copied correctly is engineering effort, and a miss at any layer is a contradiction.

The limits worth being honest about

TLS fingerprinting identifies software, not people and not devices. Every user running current Chrome on the same platform presents essentially the same JA4. That uniformity is the point (it is what makes deviations meaningful), but it also means TLS contributes nothing to telling two Chrome users apart, and a competent attacker who runs a real browser sails through the TLS layer entirely. Sessions that pass it still need the device, behavioral, and protocol-artifact layers.

There is also a deployment constraint: the fingerprint exists at the TLS terminator. If a CDN or load balancer terminates TLS in front of your application, your application never sees the handshake and needs the edge to forward the computed value. Most major CDNs now do this; self-hosted stacks need capture at the balancer.

How Foil uses it

Foil treats the TLS fingerprint as one passive layer in the joint score: captured at the edge, compared against the claimed User-Agent and platform, and cross-checked against the JavaScript environment and behavioral evidence collected by the SDK. The session detail surfaces the contradiction directly in connection_fingerprint: user_agent_alignment reports 'mismatch' when the handshake and the User-Agent disagree, ja4.hash carries the raw fingerprint, and ja4.product names the tool when the handshake matches a known automation stack. This is typically how library-based automation gets classified within the first request:

import { Foil } from "@abxy/foil-server";

const client = new Foil({ secretKey: process.env.FOIL_SECRET_KEY });
const session = await client.sessions.get(sessionId);

const { user_agent_alignment, ja4 } = session.connection_fingerprint;

if (user_agent_alignment === "mismatch") {
  // the handshake and the User-Agent disagree about what this client is
  return res.status(403).json({ error: "automation_not_permitted" });
}

if (ja4.product) {
  // the handshake matches a named tool's published fingerprint
  return res.status(403).json({ error: "automation_not_permitted" });
}

For where this sits in the full stack, bot detection covers the five layers together, and headless browser detection covers the population that passes TLS by running real Chrome.

Further reading