TLS fingerprinting identifies the software making a connection from the shape of its TLS ClientHello, the first message of the handshake. JA3 and JA4 turn that message into a short hash, and HTTP/2 settings add a second fingerprint, so a server knows whether it is talking to Chrome or to Python before any header arrives.
TL;DR
- Your HTTP library is identified during the TLS handshake, before any header is sent. The user agent is irrelevant at this stage.
- The TLS ClientHello (cipher order, extensions, curves, ALPN) is hashed into a short fingerprint. JA3 was the first widely used scheme; JA4 is the current one.
- HTTP/2 adds a second fingerprint: the SETTINGS frame values, window size, priority frames, and pseudo-header order.
- Python
requests, Gonet/http, Nodefetchand curl each produce a stable, recognizable signature that matches no browser. - The fix is impersonation at the TLS and H2 layer:
curl_cffi,tls-client, or a real browser. Header spoofing alone never works. - 2026 moved the target: Chrome's hybrid post-quantum key share (since Chrome 131), GREASE values in the signature-algorithm list (BoringSSL, mid 2026), and RFC 10015's deprecation of RSA key exchange (July 2026) each change what a "Chrome" ClientHello looks like. An impersonation profile from 2024 is now a non-Chrome fingerprint.
Where in the connection it happens
sequenceDiagram
accTitle: Where TLS and HTTP/2 fingerprinting happens
accDescr: The edge hashes the TLS ClientHello into a JA4 fingerprint and reads the HTTP/2 settings before the User-Agent header arrives, then forwards a browser match or challenges a library.
participant C as Client
participant E as Edge / WAF
participant O as Origin
rect rgba(99, 102, 241, 0.12)
C->>E: TCP SYN (options, TTL)
C->>E: TLS ClientHello (ciphers, extensions, groups, ALPN)
Note over E: hash → JA4<br/>lookup in per-browser table
E-->>C: ServerHello
end
rect rgba(168, 85, 247, 0.12)
C->>E: HTTP/2 SETTINGS + WINDOW_UPDATE
C->>E: HEADERS (:method :authority :scheme :path, User-Agent...)
Note over E: H2 fingerprint<br/>compare with claimed browser
end
alt fingerprints match a browser
rect rgba(34, 197, 94, 0.14)
E->>O: forward request
end
else fingerprint matches a library
rect rgba(239, 68, 68, 0.14)
E-->>C: 403 / challenge
end
end
The user agent arrives in the last step. By then the edge has already hashed the ClientHello and the HTTP/2 preamble. Nothing in a header can revise what the handshake said.
What the server sees first
A TLS connection begins with the client sending a ClientHello. It lists the TLS versions it supports, the cipher suites in preference order, the extensions it includes (server name, supported groups, signature algorithms, ALPN, key share, and so on), and the elliptic curves it can use. Every TLS stack has its own defaults and its own ordering. Chrome also randomizes extension order since version 110, which is itself a signal: a client claiming to be Chrome with a fixed extension order is not Chrome.
Because the ClientHello is sent in the clear, a reverse proxy or WAF can hash it without terminating TLS. This is cheap, runs on every connection, and needs no JavaScript.
JA3 and JA4
JA3 concatenates the TLS version, ciphers, extensions, curves and point formats, then MD5 hashes the string. It was simple and widely adopted, but Chrome's extension randomization broke it, since the hash changed per connection.
JA4 replaces it with a structured, human-readable fingerprint. It sorts ciphers and extensions before hashing so that randomization does not matter, and it adds the ALPN value, SNI presence, and the number of ciphers and extensions as plain fields. A JA4 string looks like t13d1516h2_8daaf6152771_b0da82dd1658: TLS 1.3, over TCP, 15 ciphers, 16 extensions, ALPN h2, then two hashes.
Vendors keep tables of JA4 values per browser version. If your fingerprint is in the "Python requests" bucket, that is the end of the analysis.
What changed in 2025 and 2026
Three things moved Chrome's handshake, and therefore the tables.
Post-quantum key exchange. Chrome has offered a hybrid X25519 plus ML-KEM-768 key share since Chrome 131 (November 2024), replacing the earlier Kyber draft. That adds a supported group and a large key share extension. Because JA4 sorts before hashing, the hash changes once rather than per connection, but any impersonation library that still sends the 2024 Kyber group or no hybrid group at all no longer matches current Chrome.
GREASE in signature algorithms. RFC 8701 always allowed GREASE values in the signature-algorithm list, but browsers did not send them there. In June 2026 BoringSSL added one behind an opt-in switch, and by October 2026 it became part of its standard GREASE behavior. The JA4 spec says to strip GREASE everywhere, but at least one large CDN's implementation only stripped it from ciphers, extensions and versions. The result, measured by the ThumbmarkJS team: one Chrome build produces up to 16 different JA4 strings, one per GREASE value, differing only in the third segment. If a vendor's table was built with that bug, current Chrome looks like sixteen rare clients.
RFC 10015. Published July 2026, it forbids clients from offering RSA and static Diffie-Hellman key exchange in TLS 1.2. Chrome 151 on macOS still offered four RSA suites (002f, 0035, 009c, 009d) in August 2026, giving the JA4 t13d1516h2_8daaf6152771_806a8c22fdea. When Chrome drops them, the cipher count falls from 15 to 11 and the first two segments change; one vendor forecasts t13d1116h2_5e2a75874763_806a8c22fdea. Treat any hard-coded JA4 allowlist as having a shelf life of one Chrome release.
Encrypted Client Hello cuts the other way. Where ECH is deployed, the real ClientHello is encrypted inside an outer one that carries little, so a passive observer gets less to hash. Adoption on scraping targets is still limited, and the edge terminating TLS sees the inner hello anyway.
The HTTP/2 layer
After the handshake, an HTTP/2 client sends a SETTINGS frame with values such as header table size, max concurrent streams and initial window size, often followed by a WINDOW_UPDATE and sometimes PRIORITY frames. It then sends the request with pseudo-headers (:method, :authority, :scheme, :path) in a specific order. Akamai published the canonical description of this fingerprint. Chrome's values and order are distinctive, and most non-browser libraries send different ones or send none.
Why impersonation libraries exist
Fixing this from a standard HTTP library is not practical, because the TLS behavior is baked into OpenSSL or the language runtime. The working approach is to use a client that was built or patched to reproduce a specific browser's handshake byte for byte:
curl-impersonateand its Python bindingcurl_cffipatch curl and its TLS backend to match Chrome, Firefox, Safari and Edge, including H2 settings.tls-client(Go, with Python bindings) uses uTLS to construct arbitrary ClientHellos.- A real browser, headed or headless, is correct at this layer by definition, though it has its own problems at the runtime layer.
Choose the impersonated browser to match the user agent and the runtime fingerprint you present elsewhere. Mixing a Chrome 130 handshake with a Firefox user agent is the contradiction described in the fingerprint anatomy post.
Demonstration: the same request, four clients
Several public endpoints echo the fingerprint they see. One widely used one is https://tls.peet.ws/api/all (verified live in October 2026). It returns the parsed ClientHello plus tls.ja3, tls.ja4, tls.peetprint, a tcpip block, and either an http1 or an http2 block depending on which protocol your client negotiated; the H2 block carries the Akamai-style fingerprint.
If your client is HTTP/1.1 only, there is no http2 key, which is itself the finding. Public endpoints change over time; if that one is down, search for a "TLS fingerprint echo" service or run your own with the open source tls-server projects.
# 1. curl (OpenSSL defaults)
curl -s https://tls.peet.ws/api/all | jq '{ja4: .tls.ja4, h2: .http2.akamai_fingerprint}'
# 2. Python requests
python3 -c 'import requests; r=requests.get("https://tls.peet.ws/api/all").json(); print(r["tls"]["ja4"], r.get("http2",{}).get("akamai_fingerprint","no h2"))'
# 3. curl_cffi impersonating Chrome
python3 -c 'from curl_cffi import requests; r=requests.get("https://tls.peet.ws/api/all", impersonate="chrome").json(); print(r["tls"]["ja4"], r.get("http2",{}).get("akamai_fingerprint","no h2"))'
For the fourth client, open the same URL in Chrome and read the JSON.
Expected result: clients 1 and 2 show short cipher lists, no HTTP/2 in the case of requests (it speaks HTTP/1.1 only), and JA4 strings that match nothing in a browser table. Client 3 and Chrome show the same JA4 prefix (t13d15...h2), the same H2 SETTINGS values, and the same pseudo-header order. Put the four JA4 strings in a table; the point of the post is in that table.
Now send a perfect Chrome user agent header from client 2 and run it again. The JA4 does not change. That is the whole lesson.
One more check that matters in 2026: run client 3 and Chrome five times each and compare the third JA4 segment. Chrome's may vary across runs because of the signature-algorithm GREASE value if the echo service does not strip it; the impersonation library's will not vary unless it has been updated to send GREASE there too. Stable where the real browser is random is a fingerprint of its own.
Read more: reading a JA4 string
t13d1516h2_8daaf6152771_806a8c22fdea (Chrome 151, macOS, August 2026) decodes as:
| Part | Meaning |
|---|---|
t |
transport is TCP (q for QUIC) |
13 |
highest TLS version offered, 1.3 |
d |
SNI is a domain (i for an IP) |
15 |
15 cipher suites |
16 |
16 extensions |
h2 |
first ALPN value |
8daaf6152771 |
truncated SHA-256 of the sorted cipher list |
806a8c22fdea |
truncated SHA-256 of sorted extensions plus signature algorithms |
Because the lists are sorted, Chrome's per-connection extension shuffling does not change the hash. JA4 also has siblings (JA4H for HTTP headers, JA4S for the server side, JA4X for certificates), which some vendors combine.
Read more: the Akamai HTTP/2 fingerprint format
The fingerprint is four groups separated by |:
- SETTINGS frame as
id:valuepairs joined by;, for example1:65536;2:0;4:6291456;6:262144. - The WINDOW_UPDATE increment, for example
15663105. - PRIORITY frames, if any, as
streamId:exclusive:dependency:weight, or0when none. - Pseudo-header order using initials:
m,a,s,pfor method, authority, scheme, path.
Chrome and Firefox differ in all four groups. Libraries that use a generic H2 implementation often send m,s,a,p or omit the window update, which is enough to classify them.
Read more: HTTP/3 and the next layer
With QUIC, the ClientHello travels inside the QUIC initial packet and is still visible for fingerprinting; JA4 prefixes it with q. QUIC transport parameters add more fields. Few scraping libraries speak HTTP/3 at all, so a site that prefers it can treat HTTP/2 downgrades from a "Chrome" client as a weak negative signal. Expect this layer to matter more as vendors instrument it.
Frequently asked questions
What is a JA4 fingerprint?
JA4 is a TLS client fingerprint format that replaced JA3. It records the transport, TLS version, cipher and extension counts and the first ALPN value in readable form, followed by hashes of the sorted cipher and extension lists, so a browser's extension shuffling does not change it.
Why does Python requests get blocked even with a Chrome user agent?
Because the block happens during the TLS handshake. The requests library's ClientHello matches no browser and it does not speak HTTP/2, so the edge classifies it before reading the User-Agent header. Only a client that reproduces a browser's handshake changes the result.
What is the difference between JA3 and JA4?
JA3 hashes the ClientHello fields in the order they were sent, so Chrome's randomized extension order produces a new JA3 on every connection. JA4 sorts the lists before hashing and adds readable fields, which makes it stable across connections and easier to inspect.
How do tools like curl_cffi handle TLS fingerprinting?
They impersonate a specific browser by sending its exact ClientHello and HTTP/2 settings. That makes the protocol layer match the claimed browser, but the profile has to be updated whenever the browser's handshake changes, which happened several times in 2026.
Sources, checked October 2026
- Post-Quantum TLS: How ML-KEM Changes Your ClientHello Fingerprint (BrowserInsight)
- JA4 Fingerprinting: Why One Chrome Browser Shows Up as 16 (ThumbmarkJS, 2026)
- RFC 10015 Will Change Chrome's JA4 Fingerprint (WebDecoy, August 2026 measurement)
- Anti-Detect Browser Benchmark 2026: 7 Tools, 651 Verdicts, includes curl_cffi 0.15 (Ian Paterson, May 2026, updated August 2026)
- tls.peet.ws echo endpoint, response shape verified October 2026
- Whose Agent Are You? (arXiv, June 2026), TLS and HTTP features for agent attribution