How Does TLS Fingerprinting Work?
Learn how TLS fingerprinting identifies clients from the ClientHello, what JA3 and JA4 hash, why headers cannot hide it, and how to match a real browser.
TLS fingerprinting identifies what software made a connection by examining how it negotiates encryption β before a single byte of HTTP is sent. It is the reason a request with perfect browser headers can still be recognised instantly as a script.
1. The Core Mechanism
Every HTTPS connection starts with a ClientHello: the client tells the server which TLS versions, cipher suites, extensions, elliptic curves and signature algorithms it supports.
Nothing in TLS specifies the order of those lists, or exactly which optional extensions to include. Each implementation makes its own choices, and those choices are consistent for a given build.
So the ClientHello is a signature of the software, not a claim about it. Chrome's differs from Firefox's, both differ from Python's requests, and all of them differ from Go's standard library.
2. What Gets Captured
A fingerprint is computed from the ordered contents of the ClientHello:
| Field | Why it varies |
|---|---|
| TLS version | Different libraries support different maximums |
| Cipher suites | Both the set and the order are implementation choices |
| Extensions | Which ones, and in what order |
| Elliptic curves | Supported groups and their preference order |
| Curve point formats | Rarely varied, still part of the input |
| ALPN | Whether HTTP/2 is offered, and how |
The list is then serialised in a fixed way and hashed. That hash is the fingerprint.
3. JA3 and JA4
JA3, published in 2017, concatenates the version, ciphers, extensions, curves and point formats into a comma-and-dash separated string, then takes its MD5. The result is a 32-character hex value.
JA4 is the successor, and it fixed two real weaknesses:
- Chrome began randomising extension order to prevent exactly this profiling. Because JA3 hashes extensions in wire order, one browser suddenly produced many different JA3 values. JA4 sorts them first, so the fingerprint stays stable.
- JA3 is one opaque hash. JA4 is a readable, segmented string β protocol, TLS version, SNI presence, counts, ALPN, then hashed components β so parts of it can be compared even when the whole does not match.
JA4 is part of a wider family covering HTTP/2 (JA4H), TCP (JA4T) and server responses (JA4S). Modern detection rarely relies on one.
4. Why Headers Cannot Hide It
This is the point that surprises people.
TLS negotiation happens before HTTP. By the time your carefully crafted User-Agent is transmitted, the handshake has already completed and the fingerprint has already been recorded.
So a mismatch is trivially detectable: a request claiming to be Chrome 120 on Windows, arriving with the TLS fingerprint of Python's urllib3, is not a borderline signal. It is a contradiction that no real browser produces, and it is one of the highest-confidence bot signals available β far stronger than a missing header, which could be a proxy or a privacy tool.
Example to Illustrate
requests.get(url, headers={"User-Agent": CHROME_UA})
The headers say Chrome. The ClientHello says OpenSSL as configured by Python. The server sees both, and the second one is the one it believes.
5. How Detection Uses It
- Known-bad matching. Fingerprints for common HTTP libraries are catalogued; a match is a strong signal.
- Consistency checks. Does the TLS fingerprint agree with the User-Agent, the HTTP/2 settings, and the header order?
- Rarity scoring. A fingerprint seen from a handful of IPs behaves differently from one shared by millions of Chrome users.
- Correlation across layers. JA4 alongside HTTP/2 frame ordering and TCP characteristics produces a profile that is hard to fake piecemeal.
- Reputation. A fingerprint associated with abuse gets treated accordingly regardless of the IP.
6. Matching a Real Browser
There are two workable approaches, and one that does not work.
Use a real browser. Playwright, Puppeteer or Selenium driving actual Chrome produces genuine Chrome TLS behaviour, because it is Chrome. Correct by construction, at the cost of running a browser.
Use an impersonating HTTP client. Libraries such as curl_cffi (Python) or utls (Go) build a ClientHello that matches a specific browser build, giving browser TLS characteristics at HTTP-client cost.
What does not work: setting headers, rotating User-Agents, or changing proxies. None of them touch the handshake. Rotating IPs while keeping a scripted TLS fingerprint just spreads one recognisable client across many addresses.
7. Real-World Examples
- A scraper blocked identically across thousands of residential IPs β the IP was never the signal.
- A
requestsscript failing wherecurl_cffiwith the same headers succeeds, changing nothing above the transport layer. - A previously working impersonation degrading after a Chrome release, because the real fingerprint moved and the imitation did not.
- Traffic flagged for claiming Chrome on Windows with a Go standard-library fingerprint β a combination that cannot occur naturally.
8. Summary
TLS fingerprinting derives an identifier from the ClientHello β versions, ciphers, extensions and curves, in the order the client offers them. JA3 hashes them in wire order; JA4 sorts extensions first and exposes readable components, surviving the randomisation that broke JA3.
Because the handshake precedes HTTP, no header can conceal it, and a fingerprint that contradicts the User-Agent is a stronger bot signal than any header anomaly. Matching a real browser means either driving one or using a client that deliberately reproduces its handshake β and, since browser fingerprints change with each release, treating that match as something to maintain rather than configure once.