Skip to content
Anti-bot systems

TLS fingerprint census: 19 clients re-measured

We re-captured the TLS ClientHello from 19 clients on 2026-10-21. Chrome 155 changed JA3 on all 24 connections; JA4 never changed.

The finding

24 of 24Chrome 155 connections sent a different JA3; all 24 shared one JA4

Chrome 155 sent 24 different JA3 hashes on 24 connections and one JA4 on all of them; each of the 19 client configurations produced exactly one JA4.

Measured by us Sample 108 ClientHello records from 19 client configurations: 16 non-browser configurations x 3 connections, 24 from Chrome 155, 24 from Chrome for Testing 148, 12 from Firefox 151 How it was measured Download the data CSV, JSON

On this page 13 sections
  1. What changed since the September study
  2. Method
  3. What each client declared
  4. JA3 against JA4 on repeat connections
  5. Firefox 151 does not shuffle and sends no GREASE
  6. What Chrome 155 added
  7. The impersonation profile matches Chrome 148, not Chrome 155
  8. Post-quantum key shares pushed ClientHellos past 1,400 bytes
  9. Two library notes
  10. Go’s default client offers HTTP/2
  11. Node 18 and Node 24 differ on every count
  12. What this means behind a proxy
  13. Reproducing this
  14. Download the data
  15. Limits

Chrome 155 sent 24 different JA3 hashes on 24 connections, and the same JA4 fingerprint on all of them. That is the clearest result from re-running our ClientHello capture on 21 October 2026 across 19 client configurations on one Mac. Other parts of the picture moved more than the September study implied. Firefox 151 did not shuffle at all, two non-browser clients did, and current Chrome has already drifted away from the Chrome profile in curl_cffi, a TLS impersonation library.

A TLS fingerprint summarises the ClientHello, the first message a client sends on a new TLS connection. That means the cipher suites, extensions, groups and signature algorithms it offers, and the order it lists them in. The message travels in clear before any HTTP header, so a server can classify the client before it reads the request. JA3 hashes those lists in the order sent. JA4 sorts them first. This page measures both on the same 108 handshakes.

What changed since the September study

The original study captured seven library configurations and one Chromium build. This re-run used the same listener approach. It re-ran the seven library configurations with the binaries still installed and added twelve new ones, three of them browsers. Against the September page, six things changed.

  • Every September library row reproduced exactly. curl 8.7.1, wget, python-requests, Python’s urllib, Node’s fetch and node:https, and Go sent the same cipher, extension, group and signature-algorithm counts, the same ALPN and the same extension order. curl 8.7.1’s JA3 is still 4f2655722e37c542ebeaf1eed48cbbbb.
  • “Browsers vary every handshake” turned out to be a Chromium result. Firefox 151.0.2 sent one extension order on 12 of 12 connections and no GREASE, so its JA3 never changed.
  • “Libraries never do” no longer holds. Deno 2.9.7, whose fetch runs on rustls, and curl_cffi 0.13.0 both changed extension order on every connection.
  • Chrome added an extension and three signature schemes. Chrome 155 sends 16 extensions, where September’s Chromium and Chrome for Testing 148 send 15. The extra one, 51764 (0xCA34), is BoringSSL’s trust anchors extension, which has no IANA codepoint yet.
  • Go’s missing ALPN came from our test code. September’s Go client built a bare http.Transport, which leaves HTTP/2 off. Go’s default client offers h2 and http/1.1.
  • The gaps are filled. September’s Chromium row had no group or signature-algorithm counts, because they were parsed but never printed. This run records them for all three browsers, along with key shares, record size, a real JA3, a JA4 and the raw ClientHello bytes.

Method

Each client connected to https://127.0.0.1:PORT/, with a fresh ephemeral port per client. A Python listener read the first TLS record of each connection, stored it and closed the socket without answering. The handshake never completes, and it does not need to. By the time the ClientHello is on the wire, the client has declared everything this page measures. Records were parsed with clienthello-probe 0.1.0, the same parser that is on PyPI, plus a short extension walker for the fields it does not return: signature algorithms, key shares, ALPS and the ECH payload size.

Non-browser clients ran three times each, as three fresh processes, with certificate checks off and no other configuration. Each browser ran headless with a throwaway profile. It loaded a local http:// page whose script called fetch() against the capture port twelve times, 250 ms apart, so every ClientHello from a browser came from one process. Browser background traffic went to a local proxy that logs the requested host and then closes the connection. It caught 10 background requests from Chrome 155 and 421 from Firefox 151, all addressed to Google or Mozilla hosts, and forwarded none of them. Chrome for Testing made none.

GREASE values (RFC 8701) are removed before anything is counted or hashed, in every list, signature algorithms included. Whether a client sends them is reported separately. JA3 follows its original definition: legacy version, ciphers, extensions, groups and point formats in the order sent, hashed with MD5. JA4 follows the FoxIO specification: a prefix with protocol, version, SNI flag, counts and the first ALPN value, then truncated SHA-256 hashes of the sorted cipher list and of the sorted extension list joined to the signature algorithms in their sent order.

Two controls ran before the census. Our JA4 code reproduced all three worked examples in the JA4 specification (8daaf6152771, e5627efa2ab1 and 6d807ffa2a79). And curl 8.7.1 produced the same JA3 that the probe’s README printed for that curl version in September, so this capture path and the earlier one agree on a known binary.

The first pass of this run was thrown away. It reported 14 different JA4 values for Chrome 155, and the cause was our code. Chrome 155 puts a GREASE value at the head of its signature algorithm list, and the first version of the script did not remove it there. After the fix the whole census ran again from scratch. In both passes every client sent the same extension set, and every client with a fixed order sent the same JA3.

Sample: 108 ClientHello records from 19 client configurations, collected on 2026-10-21 between 19:33:19 and 19:34:04 UTC on macOS 26.6.2 (arm64). That is 16 non-browser configurations at 3 connections each (48), plus 24 connections from Chrome 155, 24 from Chrome for Testing 148 and 12 from Firefox 151. The two Chromium builds opened two connections for each fetch() call, within 3 ms of each other. We did not establish why, and all 48 are counted.

What each client declared

One row per configuration, taken from its first connection. Counts exclude GREASE. Every client connected to an IP address, so none sent server_name, which is also why every JA4 on this page carries i rather than d.

Cipher suites offered in the ClientHello, GREASE excluded, by client. 2026-10-21, macOS 26.6.2 arm64. Drawn from the table below.
Cipher suites offered in the ClientHello, GREASE excluded, by client. 2026-10-21, macOS 26.6.2 arm64.. Bar chart of Ciphers by Client.19 rows, drawn from the table that follows this chart.curl 8.7.1 (macOS, LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6): 4949curl 8.23.0 (Homebrew, OpenSSL4.0.3)curl 8.23.0 (Homebrew, OpenSSL 4.0.3): 3030GNU wget 1.25.0 (OpenSSL 3.6.5)GNU wget 1.25.0 (OpenSSL 3.6.5): 6868requests 2.32.3 (Python 3.9.6)requests 2.32.3 (Python 3.9.6): 3838urllib (Python 3.9.6)urllib (Python 3.9.6): 3838httpx 0.27.2 (Python 3.9.6)httpx 0.27.2 (Python 3.9.6): 2828httpx 0.27.2, http2=Truehttpx 0.27.2, http2=True: 2828urllib (Python 3.15.0, OpenSSL4.0.3)urllib (Python 3.15.0, OpenSSL 4.0.3): 1717Go 1.24.4, bare http.TransportGo 1.24.4, bare http.Transport: 1313Go 1.24.4, http.DefaultClientGo 1.24.4, http.DefaultClient: 1313Node 24.2.0 fetchNode 24.2.0 fetch: 5252Node 24.2.0 node:httpsNode 24.2.0 node:https: 5252Node 18.13.0 fetchNode 18.13.0 fetch: 5959Node 18.13.0 node:httpsNode 18.13.0 node:https: 5959Deno 2.9.7 fetchDeno 2.9.7 fetch: 1010curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome": 1515Chrome 155.0.8059.85Chrome 155.0.8059.85: 1515Chrome for Testing 148.0.7778.96Chrome for Testing 148.0.7778.96: 1515Firefox 151.0.2Firefox 151.0.2: 1616010203040506070Cipher suites offered in the ClientHello, GREASE excluded, by client. 2026-10-21, macOS 26.6.2 arm64.. Bar chart of Ciphers by Client.19 rows, drawn from the table that follows this chart.curl 8.7.1 (macOS,LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6): 4949curl 8.23.0 (Homebrew,OpenSSL 4.0.3)curl 8.23.0 (Homebrew, OpenSSL 4.0.3): 3030GNU wget 1.25.0(OpenSSL 3.6.5)GNU wget 1.25.0 (OpenSSL 3.6.5): 6868requests 2.32.3 (Python3.9.6)requests 2.32.3 (Python 3.9.6): 3838urllib (Python 3.9.6)urllib (Python 3.9.6): 3838httpx 0.27.2 (Python3.9.6)httpx 0.27.2 (Python 3.9.6): 2828httpx 0.27.2, http2=Truehttpx 0.27.2, http2=True: 2828urllib (Python 3.15.0,OpenSSL 4.0.3)urllib (Python 3.15.0, OpenSSL 4.0.3): 1717Go 1.24.4, barehttp.TransportGo 1.24.4, bare http.Transport: 1313Go 1.24.4,http.DefaultClientGo 1.24.4, http.DefaultClient: 1313Node 24.2.0 fetchNode 24.2.0 fetch: 5252Node 24.2.0 node:httpsNode 24.2.0 node:https: 5252Node 18.13.0 fetchNode 18.13.0 fetch: 5959Node 18.13.0 node:httpsNode 18.13.0 node:https: 5959Deno 2.9.7 fetchDeno 2.9.7 fetch: 1010curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome": 1515Chrome 155.0.8059.85Chrome 155.0.8059.85: 1515Chrome for Testing148.0.7778.96Chrome for Testing 148.0.7778.96: 1515Firefox 151.0.2Firefox 151.0.2: 16160255075
Client Ciphers Extensions Groups Sig algs ALPN GREASE Highest TLS Key shares
curl 8.7.1 (macOS, LibreSSL 3.3.6) 49 6 4 11 h2, http/1.1 no 1.3 x25519
curl 8.23.0 (Homebrew, OpenSSL 4.0.3) 30 11 11 27 h2, http/1.1 no 1.3 X25519MLKEM768, x25519
GNU wget 1.25.0 (OpenSSL 3.6.5) 68 11 8 26 none no 1.3 X25519MLKEM768, x25519
requests 2.32.3 (Python 3.9.6) 38 4 3 13 http/1.1 no 1.2 (no supported_versions) none sent
urllib (Python 3.9.6) 38 4 3 13 none no 1.2 (no supported_versions) none sent
httpx 0.27.2 (Python 3.9.6) 28 5 3 13 http/1.1 no 1.2 (no supported_versions) none sent
httpx 0.27.2, http2=True 28 5 3 13 http/1.1, h2 no 1.2 (no supported_versions) none sent
urllib (Python 3.15.0, OpenSSL 4.0.3) 17 10 11 27 none no 1.3 X25519MLKEM768, x25519
Go 1.24.4, bare http.Transport 13 9 5 12 none no 1.3 X25519MLKEM768, x25519
Go 1.24.4, http.DefaultClient 13 10 5 12 h2, http/1.1 no 1.3 X25519MLKEM768, x25519
Node 24.2.0 fetch 52 11 8 26 http/1.1 no 1.3 X25519MLKEM768, x25519
Node 24.2.0 node:https 52 10 8 26 none no 1.3 X25519MLKEM768, x25519
Node 18.13.0 fetch 59 9 10 20 none no 1.3 x25519
Node 18.13.0 node:https 59 9 10 20 none no 1.3 x25519
Deno 2.9.7 fetch 10 10 4 10 h2, http/1.1 no 1.3 x25519
curl_cffi 0.13.0, impersonate="chrome" 15 15 4 8 h2, http/1.1 yes 1.3 X25519MLKEM768, x25519
Chrome 155.0.8059.85 15 16 4 11 h2, http/1.1 yes 1.3 X25519MLKEM768, x25519
Chrome for Testing 148.0.7778.96 15 15 4 8 h2, http/1.1 yes 1.3 X25519MLKEM768, x25519
Firefox 151.0.2 16 16 7 11 h2, http/1.1 no 1.3 X25519MLKEM768, x25519, secp256r1

Cipher count alone still separates most clients: Deno offers 10 suites, Go 13, Chrome 15, Firefox 16 and wget 68. The column that surprised us was the highest TLS version. The four configurations that use Python 3.9.6’s own ssl module are linked against LibreSSL 2.8.3 and send no supported_versions extension at all, so they cannot negotiate TLS 1.3. The same urllib code on Python 3.15.0 with OpenSSL 4.0.3 offers TLS 1.3, 17 ciphers instead of 38 and 27 signature algorithms instead of 13. Python 3.10 changed the ssl module’s default ciphers and minimum version, and the TLS library underneath also changed, so this run cannot separate the two effects.

Two pairs are indistinguishable on everything a fingerprint hashes. Node 18.13.0’s fetch and node:https produce the same JA3 and the same JA4, because that fetch sends no ALPN. httpx with and without http2=True also match. httpx 0.27.2 lists http/1.1 before h2, and JA4 reads only the first ALPN value. On the same interpreter, httpx and requests differ: httpx sets its own cipher string, so it offers 28 suites where requests offers 38. Our requests and httpx proxy guide covers the request side of both libraries.

JA3 against JA4 on repeat connections

Repeat connections from one client: distinct extension orders, JA3 hashes and JA4 fingerprints Drawn from the table below.
Repeat connections from one client: distinct extension orders, JA3 hashes and JA4 fingerprints. Bar chart of Connections, Extension orders, JA3 values, JA4 values by Client.7 rows, drawn from the table that follows this chart.Chrome 155.0.8059.85Chrome 155.0.8059.85 · Connections: 24Chrome 155.0.8059.85 · Extension orders: 24Chrome 155.0.8059.85 · JA3 values: 24Chrome 155.0.8059.85 · JA4 values: 1Chrome for Testing 148.0.7778.96Chrome for Testing 148.0.7778.96 · Connections: 24Chrome for Testing 148.0.7778.96 · Extension orders: 24Chrome for Testing 148.0.7778.96 · JA3 values: 24Chrome for Testing 148.0.7778.96 · JA4 values: 1Firefox 151.0.2Firefox 151.0.2 · Connections: 12Firefox 151.0.2 · Extension orders: 1Firefox 151.0.2 · JA3 values: 1Firefox 151.0.2 · JA4 values: 1Deno 2.9.7 fetchDeno 2.9.7 fetch · Connections: 3Deno 2.9.7 fetch · Extension orders: 3Deno 2.9.7 fetch · JA3 values: 3Deno 2.9.7 fetch · JA4 values: 1curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome" · Connections: 3curl_cffi 0.13.0, impersonate="chrome" · Extension orders: 3curl_cffi 0.13.0, impersonate="chrome" · JA3 values: 3curl_cffi 0.13.0, impersonate="chrome" · JA4 values: 1curl 8.7.1 (macOS, LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6) · Connections: 3curl 8.7.1 (macOS, LibreSSL 3.3.6) · Extension orders: 1curl 8.7.1 (macOS, LibreSSL 3.3.6) · JA3 values: 1curl 8.7.1 (macOS, LibreSSL 3.3.6) · JA4 values: 1Go 1.24.4, http.DefaultClientGo 1.24.4, http.DefaultClient · Connections: 3Go 1.24.4, http.DefaultClient · Extension orders: 1Go 1.24.4, http.DefaultClient · JA3 values: 1Go 1.24.4, http.DefaultClient · JA4 values: 10510152025Repeat connections from one client: distinct extension orders, JA3 hashes and JA4 fingerprints. Bar chart of Connections, Extension orders, JA3 values, JA4 values by Client.7 rows, drawn from the table that follows this chart.Chrome 155.0.8059.85Chrome 155.0.8059.85 · Connections: 24Chrome 155.0.8059.85 · Extension orders: 24Chrome 155.0.8059.85 · JA3 values: 24Chrome 155.0.8059.85 · JA4 values: 1Chrome for Testing148.0.7778.96Chrome for Testing 148.0.7778.96 · Connections: 24Chrome for Testing 148.0.7778.96 · Extension orders: 24Chrome for Testing 148.0.7778.96 · JA3 values: 24Chrome for Testing 148.0.7778.96 · JA4 values: 1Firefox 151.0.2Firefox 151.0.2 · Connections: 12Firefox 151.0.2 · Extension orders: 1Firefox 151.0.2 · JA3 values: 1Firefox 151.0.2 · JA4 values: 1Deno 2.9.7 fetchDeno 2.9.7 fetch · Connections: 3Deno 2.9.7 fetch · Extension orders: 3Deno 2.9.7 fetch · JA3 values: 3Deno 2.9.7 fetch · JA4 values: 1curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome" · Connections: 3curl_cffi 0.13.0, impersonate="chrome" · Extension orders: 3curl_cffi 0.13.0, impersonate="chrome" · JA3 values: 3curl_cffi 0.13.0, impersonate="chrome" · JA4 values: 1curl 8.7.1 (macOS,LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6) · Connections: 3curl 8.7.1 (macOS, LibreSSL 3.3.6) · Extension orders: 1curl 8.7.1 (macOS, LibreSSL 3.3.6) · JA3 values: 1curl 8.7.1 (macOS, LibreSSL 3.3.6) · JA4 values: 1Go 1.24.4,http.DefaultClientGo 1.24.4, http.DefaultClient · Connections: 3Go 1.24.4, http.DefaultClient · Extension orders: 1Go 1.24.4, http.DefaultClient · JA3 values: 1Go 1.24.4, http.DefaultClient · JA4 values: 10510152025
Client Connections Extension orders JA3 values JA4 values
Chrome 155.0.8059.85 24 24 24 1
Chrome for Testing 148.0.7778.96 24 24 24 1
Firefox 151.0.2 12 1 1 1
Deno 2.9.7 fetch 3 3 3 1
curl_cffi 0.13.0, impersonate="chrome" 3 3 3 1
curl 8.7.1 (macOS, LibreSSL 3.3.6) 3 1 1 1
Go 1.24.4, http.DefaultClient 3 1 1 1

Four configurations changed extension order on every connection: both Chromium builds, Deno and curl_cffi. For them JA3 is a per-connection random value. Chrome 155 produced 24 JA3 hashes in 24 connections, the same pattern September saw from its Chromium. JA4 sorts before it hashes, so all four collapsed to one value each. Across the whole census, 108 records gave 67 distinct JA3 hashes and 16 distinct JA4 values for 19 configurations.

So the useful question is no longer whether JA3 is stable. It is whether JA4 separates the clients, and here it separated most of them. The three collisions are the two pairs above and one more, which matters more to anyone running a scraper: curl_cffi’s Chrome profile and Chrome for Testing 148 share a JA4 exactly.

Deno’s shuffle comes from rustls. Its ClientHello encoder writes order-insensitive extensions “in random order, controlled by” a per-connection seed, and the Deno binary we ran embeds rustls 0.23.40. Deno also lists X25519MLKEM768 in its supported groups but sends only an x25519 key share. Every OpenSSL 3.5 or later client here sends both.

The full set of fingerprints, for anyone who wants to match them against their own logs:

Client JA4 JA3 (MD5)
curl 8.7.1 (macOS, LibreSSL 3.3.6) t13i4906h2_0d8feac7bc37_7395dae3b2f3 4f2655722e37c542ebeaf1eed48cbbbb
curl 8.23.0 (Homebrew, OpenSSL 4.0.3) t13i3011h2_1d37bd780c83_8deb338c2fb1 9e95db9dc57e8b0a8c8736ab3ed301ca
GNU wget 1.25.0 (OpenSSL 3.6.5) t13i681100_13e0e9e1c501_89ab6efea773 0a35e0b374f9077f46115637ab06ae88
requests 2.32.3 (Python 3.9.6) t12i3804h1_10ed599f3404_aaf95bb78ec9 954ecce4162f158cc68e595c62bcf4ce
urllib (Python 3.9.6) t12i380400_10ed599f3404_67a080e8974e 1644236e445dd636f36aa6d75fa06569
httpx 0.27.2 (Python 3.9.6) t12i2805h1_d943125447b4_67a080e8974e b0c65d8a09c77695b6f3cf7f6c916260
httpx 0.27.2, http2=True t12i2805h1_d943125447b4_67a080e8974e b0c65d8a09c77695b6f3cf7f6c916260
urllib (Python 3.15.0, OpenSSL 4.0.3) t13i171000_ab0a1bf427ad_0d46a1bf4a7c 66e4541357f30a0c6b7e8e46330ef5db
Go 1.24.4, bare http.Transport t13i130900_f57a46bbacb6_e7c285222651 6639916abfac56b9257ca216677085bc
Go 1.24.4, http.DefaultClient t13i1310h2_f57a46bbacb6_e7c285222651 faa162b5a653756fba26803ea81b362f
Node 24.2.0 fetch t13i5211h1_b262b3658495_8e6e362c5eac e728b1b92f196b7dc056314ba5b9d3db
Node 24.2.0 node:https t13i521000_b262b3658495_8e6e362c5eac 74fc12d4399034848f23564f342e65b9
Node 18.13.0 fetch t13i590900_a33745022dd6_1f22a2ca17c4 6521bd74aad3476cdb3daa827288ec35
Node 18.13.0 node:https t13i590900_a33745022dd6_1f22a2ca17c4 6521bd74aad3476cdb3daa827288ec35
Deno 2.9.7 fetch t13i1010h2_61a7ad8aa9b6_f9531d972513 changes per connection (3 of 3 distinct)
curl_cffi 0.13.0, impersonate="chrome" t13i1515h2_8daaf6152771_d8a2da3f94cd changes per connection (3 of 3 distinct)
Chrome 155.0.8059.85 t13i1516h2_8daaf6152771_cb7bf5808d99 changes per connection (24 of 24 distinct)
Chrome for Testing 148.0.7778.96 t13i1515h2_8daaf6152771_d8a2da3f94cd changes per connection (24 of 24 distinct)
Firefox 151.0.2 t13i1616h2_86a278354501_3cbfd9057e0d dfc1768fa3cf4a239df894bbfbe05c3d

Firefox 151 does not shuffle and sends no GREASE

The September page said Firefox sends GREASE. It had not measured Firefox. Firefox 151.0.2 sent the same 16 extensions in the same order on all 12 connections:

23 65281 10 11 35 16 5 34 18 51 43 13 45 28 27 65037

No GREASE value appeared in its ciphers, extensions, groups or signature algorithms, so its JA3, dfc1768fa3cf4a239df894bbfbe05c3d, was identical every time. It did send an encrypted_client_hello extension, 281 bytes on every connection, where the Chromium builds varied theirs between 186 and 282 bytes. It is also the only client here with three key shares (X25519MLKEM768, x25519 and secp256r1), and the only one sending record_size_limit (28) and delegated_credential (34). The Firefox binary carries a preference named security.tls.grease_http3_enable, which suggests its GREASE is reserved for HTTP/3. This census tested TLS over TCP only.

Of the three browsers measured, Firefox is the one JA3 still pins down, and Chrome is the one it cannot. September’s line that TLS fingerprinting identifies libraries reliably and browsers barely at all holds for Chromium and inverts for Firefox.

What Chrome 155 added

Field Chrome for Testing 148.0.7778.96 Chrome 155.0.8059.85
Extensions, GREASE excluded 15 16
trust_anchors, 51764 (0xCA34) not sent sent on 24 of 24
Signature algorithms 8 11
mldsa44, mldsa65, mldsa87 (0x0904 to 0x0906) not offered offered
GREASE in signature_algorithms no yes, first entry
ClientHello size 1,701 to 1,797 bytes 1,899 to 1,995 bytes
Cipher hash (JA4 part b) 8daaf6152771 8daaf6152771
JA4 t13i1515h2_8daaf6152771_d8a2da3f94cd t13i1516h2_8daaf6152771_cb7bf5808d99

BoringSSL’s header defines 0xCA34 as TLSEXT_TYPE_trust_anchors and notes that it is “not an IANA defined extension number”, with a TODO to replace it once the IETF Trust Anchor Identifiers draft is standardised. The IANA SignatureScheme registry names the three new schemes mldsa44, mldsa65 and mldsa87: post-quantum signatures that Chrome now says it will accept. The cipher list did not move. Both builds hash to 8daaf6152771, the value the JA4 specification uses in its own worked example.

One smaller detail. Every Chromium-based capture, curl_cffi included, sent application settings as extension 17613, BoringSSL’s current codepoint. The older 17513, which clienthello-probe 0.1.0 still labels by name, did not appear once, so the probe prints the extension as a bare number.

The impersonation profile matches Chrome 148, not Chrome 155

curl_cffi 0.13.0 resolves impersonate="chrome" to its chrome136 profile. On the wire it matched Chrome for Testing 148 on every field JA4 reads: the same 15 ciphers, the same 15 extensions including ECH and ALPS, the same 8 signature algorithms, and the same JA4. It shuffled extension order as Chrome does, and its ECH payload varied in size the same way.

Against Chrome 155 it is one extension and three signature schemes short, and a JA4 comparison can tell the two apart from a single connection. A request that claims a Chrome 155 User-Agent while sending the Chrome 148 handshake is internally inconsistent. Our study of default request headers asks the same question one layer up: what a client reveals before anyone configures it.

Post-quantum key shares pushed ClientHellos past 1,400 bytes

ClientHello record size on the wire, smallest and largest seen per client Drawn from the table below.
ClientHello record size on the wire, smallest and largest seen per client. Bar chart of Smallest, Largest by Client.19 rows, drawn from the table that follows this chart.httpx 0.27.2 (Python 3.9.6)httpx 0.27.2 (Python 3.9.6) · Smallest: 175175httpx 0.27.2 (Python 3.9.6) · Largest: 175175httpx 0.27.2, http2=Truehttpx 0.27.2, http2=True · Smallest: 178178httpx 0.27.2, http2=True · Largest: 178178urllib (Python 3.9.6)urllib (Python 3.9.6) · Smallest: 180180urllib (Python 3.9.6) · Largest: 180180requests 2.32.3 (Python 3.9.6)requests 2.32.3 (Python 3.9.6) · Smallest: 191191requests 2.32.3 (Python 3.9.6) · Largest: 191191Deno 2.9.7 fetchDeno 2.9.7 fetch · Smallest: 240240Deno 2.9.7 fetch · Largest: 240240curl 8.7.1 (macOS, LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6) · Smallest: 301301curl 8.7.1 (macOS, LibreSSL 3.3.6) · Largest: 301301Node 18.13.0 fetchNode 18.13.0 fetch · Smallest: 349349Node 18.13.0 fetch · Largest: 349349Node 18.13.0 node:httpsNode 18.13.0 node:https · Smallest: 349349Node 18.13.0 node:https · Largest: 349349Go 1.24.4, bare http.TransportGo 1.24.4, bare http.Transport · Smallest: 1,4531,453Go 1.24.4, bare http.Transport · Largest: 1,4531,453Go 1.24.4, http.DefaultClientGo 1.24.4, http.DefaultClient · Smallest: 1,4711,471Go 1.24.4, http.DefaultClient · Largest: 1,4711,471urllib (Python 3.15.0, OpenSSL4.0.3)urllib (Python 3.15.0, OpenSSL 4.0.3) · Smallest: 1,5041,504urllib (Python 3.15.0, OpenSSL 4.0.3) · Largest: 1,5041,504curl 8.23.0 (Homebrew, OpenSSL4.0.3)curl 8.23.0 (Homebrew, OpenSSL 4.0.3) · Smallest: 1,5481,548curl 8.23.0 (Homebrew, OpenSSL 4.0.3) · Largest: 1,5481,548Node 24.2.0 node:httpsNode 24.2.0 node:https · Smallest: 1,5661,566Node 24.2.0 node:https · Largest: 1,5661,566Node 24.2.0 fetchNode 24.2.0 fetch · Smallest: 1,5811,581Node 24.2.0 fetch · Largest: 1,5811,581GNU wget 1.25.0 (OpenSSL 3.6.5)GNU wget 1.25.0 (OpenSSL 3.6.5) · Smallest: 1,6021,602GNU wget 1.25.0 (OpenSSL 3.6.5) · Largest: 1,6021,602curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome" · Smallest: 1,7011,701curl_cffi 0.13.0, impersonate="chrome" · Largest: 1,7651,765Chrome for Testing 148.0.7778.96Chrome for Testing 148.0.7778.96 · Smallest: 1,7011,701Chrome for Testing 148.0.7778.96 · Largest: 1,7971,797Firefox 151.0.2Firefox 151.0.2 · Smallest: 1,8741,874Firefox 151.0.2 · Largest: 1,8741,874Chrome 155.0.8059.85Chrome 155.0.8059.85 · Smallest: 1,8991,899Chrome 155.0.8059.85 · Largest: 1,9951,99501K2K bytesClientHello record size on the wire, smallest and largest seen per client. Bar chart of Smallest, Largest by Client.19 rows, drawn from the table that follows this chart.httpx 0.27.2 (Python3.9.6)httpx 0.27.2 (Python 3.9.6) · Smallest: 175175httpx 0.27.2 (Python 3.9.6) · Largest: 175175httpx 0.27.2, http2=Truehttpx 0.27.2, http2=True · Smallest: 178178httpx 0.27.2, http2=True · Largest: 178178urllib (Python 3.9.6)urllib (Python 3.9.6) · Smallest: 180180urllib (Python 3.9.6) · Largest: 180180requests 2.32.3 (Python3.9.6)requests 2.32.3 (Python 3.9.6) · Smallest: 191191requests 2.32.3 (Python 3.9.6) · Largest: 191191Deno 2.9.7 fetchDeno 2.9.7 fetch · Smallest: 240240Deno 2.9.7 fetch · Largest: 240240curl 8.7.1 (macOS,LibreSSL 3.3.6)curl 8.7.1 (macOS, LibreSSL 3.3.6) · Smallest: 301301curl 8.7.1 (macOS, LibreSSL 3.3.6) · Largest: 301301Node 18.13.0 fetchNode 18.13.0 fetch · Smallest: 349349Node 18.13.0 fetch · Largest: 349349Node 18.13.0 node:httpsNode 18.13.0 node:https · Smallest: 349349Node 18.13.0 node:https · Largest: 349349Go 1.24.4, barehttp.TransportGo 1.24.4, bare http.Transport · Smallest: 1,4531,453Go 1.24.4, bare http.Transport · Largest: 1,4531,453Go 1.24.4,http.DefaultClientGo 1.24.4, http.DefaultClient · Smallest: 1,4711,471Go 1.24.4, http.DefaultClient · Largest: 1,4711,471urllib (Python 3.15.0,OpenSSL 4.0.3)urllib (Python 3.15.0, OpenSSL 4.0.3) · Smallest: 1,5041,504urllib (Python 3.15.0, OpenSSL 4.0.3) · Largest: 1,5041,504curl 8.23.0 (Homebrew,OpenSSL 4.0.3)curl 8.23.0 (Homebrew, OpenSSL 4.0.3) · Smallest: 1,5481,548curl 8.23.0 (Homebrew, OpenSSL 4.0.3) · Largest: 1,5481,548Node 24.2.0 node:httpsNode 24.2.0 node:https · Smallest: 1,5661,566Node 24.2.0 node:https · Largest: 1,5661,566Node 24.2.0 fetchNode 24.2.0 fetch · Smallest: 1,5811,581Node 24.2.0 fetch · Largest: 1,5811,581GNU wget 1.25.0(OpenSSL 3.6.5)GNU wget 1.25.0 (OpenSSL 3.6.5) · Smallest: 1,6021,602GNU wget 1.25.0 (OpenSSL 3.6.5) · Largest: 1,6021,602curl_cffi 0.13.0,impersonate="chrome"curl_cffi 0.13.0, impersonate="chrome" · Smallest: 1,7011,701curl_cffi 0.13.0, impersonate="chrome" · Largest: 1,7651,765Chrome for Testing148.0.7778.96Chrome for Testing 148.0.7778.96 · Smallest: 1,7011,701Chrome for Testing 148.0.7778.96 · Largest: 1,7971,797Firefox 151.0.2Firefox 151.0.2 · Smallest: 1,8741,874Firefox 151.0.2 · Largest: 1,8741,874Chrome 155.0.8059.85Chrome 155.0.8059.85 · Smallest: 1,8991,899Chrome 155.0.8059.85 · Largest: 1,9951,99501K2K bytes
Client Smallest Largest Key shares sent
httpx 0.27.2 (Python 3.9.6) 175 175 none sent
httpx 0.27.2, http2=True 178 178 none sent
urllib (Python 3.9.6) 180 180 none sent
requests 2.32.3 (Python 3.9.6) 191 191 none sent
Deno 2.9.7 fetch 240 240 x25519
curl 8.7.1 (macOS, LibreSSL 3.3.6) 301 301 x25519
Node 18.13.0 fetch 349 349 x25519
Node 18.13.0 node:https 349 349 x25519
Go 1.24.4, bare http.Transport 1,453 1,453 X25519MLKEM768, x25519
Go 1.24.4, http.DefaultClient 1,471 1,471 X25519MLKEM768, x25519
urllib (Python 3.15.0, OpenSSL 4.0.3) 1,504 1,504 X25519MLKEM768, x25519
curl 8.23.0 (Homebrew, OpenSSL 4.0.3) 1,548 1,548 X25519MLKEM768, x25519
Node 24.2.0 node:https 1,566 1,566 X25519MLKEM768, x25519
Node 24.2.0 fetch 1,581 1,581 X25519MLKEM768, x25519
GNU wget 1.25.0 (OpenSSL 3.6.5) 1,602 1,602 X25519MLKEM768, x25519
curl_cffi 0.13.0, impersonate="chrome" 1,701 1,765 X25519MLKEM768, x25519
Chrome for Testing 148.0.7778.96 1,701 1,797 X25519MLKEM768, x25519
Firefox 151.0.2 1,874 1,874 X25519MLKEM768, x25519, secp256r1
Chrome 155.0.8059.85 1,899 1,995 X25519MLKEM768, x25519

Every configuration that sent an X25519MLKEM768 key share sent between 1,453 and 1,995 bytes. Every one that did not sent between 175 and 349. The hybrid key share alone is 1,216 bytes in every capture that carries it. Go 1.24 turned it on by default, and OpenSSL 3.5 changed its default key shares to X25519MLKEM768 and X25519, which is exactly what the Homebrew curl, wget, Node 24 and Python 3.15 builds here send. Go’s release notes give the practical reason to care: some servers mishandle the larger records and the handshake times out, and GODEBUG=tlsmlkem=0 turns the key share off.

Size is not stable either. The Chromium builds and curl_cffi varied the ECH payload between connections, so the same Chrome process sent ClientHellos up to 96 bytes apart. September did not record key shares or record size, so this run cannot say whether the reproduced library builds already sent the hybrid share then. Their extension lists and group counts were identical, which suggests they did.

Two library notes

Go’s default client offers HTTP/2

September’s table showed Go with no ALPN. The program behind that row built &http.Transport{TLSClientConfig: ...}, and Go’s own documentation says that providing a TLSClientConfig “conservatively disables HTTP/2” unless ForceAttemptHTTP2 is set. DefaultTransport sets it. Calling http.Get with the default client sends ALPN h2 and http/1.1 and 10 extensions instead of 9. The cipher list, groups and signature algorithms are identical, so both variants share JA4’s two hashes and differ only in the prefix: t13i130900 against t13i1310h2.

Node 18 and Node 24 differ on every count

Node 18.13.0 on OpenSSL 3.0.7 offers 59 ciphers, 10 groups and 20 signature algorithms, sends no ALPN from either API, and sends an x25519 key share only. Node 24.2.0 on OpenSSL 3.6.5 offers 52, 8 and 26, and sends the hybrid share. A Node scraper’s fingerprint moves with the runtime upgrade, with no code change at all. Our Node.js proxy guide covers the request layer.

What this means behind a proxy

Nothing in this census depends on the network path. The ClientHello is built by the client’s TLS library, and an HTTP CONNECT tunnel is defined as blind forwarding of bytes in both directions, so the handshake that reaches the target is the one the client wrote. A residential exit carries the same JA4 as a datacenter one. When a request with clean headers on a clean address is still refused, the guide to 403, 407 and 429 helps separate a proxy refusal from a target refusal. On the target side, the handshake is one of the first things to check.

What changes the fingerprint is the TLS stack: a different library, a different build of the same library, a runtime upgrade, or a real browser engine. This census found all four at work on one machine.

Reproducing this

The parser is on PyPI. The listener below prints JA4 and JA3 for every client that connects. It reproduced the census values for curl 8.7.1, curl 8.23.0, curl_cffi and Go under Python 3.9.6, and for Chrome for Testing 148 under Python 3.15.0.

pip install clienthello-probe==0.1.0
python3 ja4probe.py 8443
# ja4probe.py: print JA4 and JA3 for every client that connects.
# Tested with clienthello-probe 0.1.0 on Python 3.9.6 and 3.15.0.
import hashlib, socket, struct, sys
from clienthello_probe import parse_client_hello
from clienthello_probe.parser import GREASE

def h12(s):
    return hashlib.sha256(s.encode()).hexdigest()[:12]

def sigalgs(raw):
    i = 43 + 1 + raw[43]                            # skip header, random, session id
    i += 2 + struct.unpack_from("!H", raw, i)[0]    # cipher suites
    i += 1 + raw[i]                                 # compression methods
    end = i + 2 + struct.unpack_from("!H", raw, i)[0]
    i += 2
    while i + 4 <= end:
        t, n = struct.unpack_from("!HH", raw, i)
        if t == 13:                                 # signature_algorithms
            b = raw[i + 6:i + 4 + n]
            return [x for x in struct.unpack("!%dH" % (len(b) // 2), b) if x not in GREASE]
        i += 4 + n
    return []

def alpn_chars(p):
    if not p:
        return "00"
    if p[0].isalnum() and p[-1].isalnum() and p.isascii():
        return p[0] + p[-1]
    x = p.encode("latin-1").hex()
    return x[0] + x[-1]

def ja4(raw):
    h = parse_client_hello(raw)
    cs = [c for c in h["ciphers"] if c not in GREASE]
    ex = [e for e in h["extensions"] if e not in GREASE]
    ver = max(h["supported_versions"] or [h["hello_version"]])
    a = "t%s%s%02d%02d%s" % (
        {0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10"}.get(ver, "00"),
        "d" if 0 in ex else "i", min(len(cs), 99), min(len(ex), 99),
        alpn_chars(h["alpn"][0] if h["alpn"] else ""))
    b = h12(",".join(sorted("%04x" % c for c in cs))) if cs else "0" * 12
    c = ",".join(sorted("%04x" % e for e in ex if e not in (0, 16)))
    sa = sigalgs(raw)
    if sa:
        c += "_" + ",".join("%04x" % s for s in sa)
    return a + "_" + b + "_" + h12(c), h["ja3"]["md5"]

port = int(sys.argv[1]) if len(sys.argv) > 1 else 8443
srv = socket.create_server(("127.0.0.1", port))
print("listening on 127.0.0.1:%d" % port, flush=True)
while True:
    conn, _ = srv.accept()
    with conn:
        conn.settimeout(5)
        raw = conn.recv(5)
        need = 5 + int.from_bytes(raw[3:5], "big")
        while len(raw) < need:
            chunk = conn.recv(need - len(raw))
            if not chunk:
                break
            raw += chunk
    print("JA4 %s  JA3 %s" % ja4(raw), flush=True)

Point a client at it from a second terminal. This is the real output for the macOS system curl:

$ curl -sk https://127.0.0.1:8443/
JA4 t13i4906h2_0d8feac7bc37_7395dae3b2f3  JA3 4f2655722e37c542ebeaf1eed48cbbbb

The client reports a connection error, because the listener closes after reading. To repeat the browser result, serve a local page that calls fetch() against the listener a dozen times. Navigating to the listener directly works too, but gives fewer connections per launch. To run the same check through a proxy, our guide to curl with a proxy shows the flags. Glossary background is in JA3 fingerprint and TLS fingerprinting.

Download the data

All 108 records, one row per connection, with the cipher, group and signature-algorithm lists in hex, the extension order, JA3, JA4, ECH size and record size. The JSON adds the raw ClientHello bytes in hex, every client’s version string, the JA4 self-test results and the background-request counts. Licensed CC BY 4.0: reuse it with attribution to proxy.wiki.

A blank cell means the field does not apply, for example ech_bytes for a client that sent no ECH extension. It never means zero.

Limits

  • One machine, one 45-second window. macOS 26.6.2 on arm64, 2026-10-21. The same library on Linux, or linked against another TLS build, sends a different ClientHello. The two curl rows and the two Python rows show how much the build matters.
  • Headless browsers, no Safari. All three browsers ran headless with fresh profiles. We have no evidence that headless mode changes the ClientHello, but we did not compare it against a headed window. Safari 27.0 is installed on the test machine but has no headless mode, so it is not measured.
  • Not the same Chromium as September. September’s browser row came from an embedded Chromium whose version was not recorded. Its extension set matches Chrome for Testing 148 exactly, and that is as close as the comparison gets.
  • Three runs per library. That is enough to see a shuffle (Deno and curl_cffi changed order on all three) but not to rule out a rare one.
  • No SNI, no resumption, no HTTP/3. Clients connected to 127.0.0.1, so none sent server_name, and no handshake completed, so nothing resumed. A real hostname adds extension 0 and flips JA4’s i to d. QUIC handshakes were not captured.
  • Unexplained double connections. Both Chromium builds opened two connections per fetch() against a listener that closes at once. We counted every one and did not investigate the cause.
  • September versions partly unrecorded. The original did not record wget’s or Node’s exact versions. Identical output is the evidence that those rows describe the same builds.
  • No claim about detection. This measures what clients send. What any given server does with JA3 or JA4 is outside what a local listener can observe.

Download the data

The raw results behind this study, as files. Reuse them under CC BY 4.0 with a link to this page.

Frequently asked questions

What is a TLS fingerprint?
A summary of the ClientHello a client sends when it opens a TLS connection: the cipher suites, extensions, groups and signature algorithms it offers, and the order it lists them in. It travels in clear before any HTTP header. JA3 hashes those lists in the order sent. JA4 sorts them first, so it survives the extension shuffling that Chrome does.
Is JA3 still useful?
For clients with a fixed extension order, yes. 15 of the 19 configurations we measured kept one JA3 on every connection, Firefox 151 included. For Chrome it is not: 24 connections from Chrome 155 produced 24 JA3 hashes. Deno 2.9.7 and curl_cffi 0.13.0 also changed JA3 on every connection.
Why does JA4 stay the same when JA3 changes?
JA4 sorts the cipher and extension lists before hashing, so a reordered ClientHello hashes to the same value, and it strips GREASE from every list. In our census each of the 19 configurations produced exactly one JA4, including 24 connections each from two Chromium builds.
Does Firefox randomise its TLS extension order?
Not in our capture. Firefox 151.0.2 sent the same 16 extensions in the same order on all 12 connections, with no GREASE values, so its JA3 was dfc1768fa3cf4a239df894bbfbe05c3d every time.
Can curl_cffi make a client look like Chrome?
Like Chrome 148, on the fields JA4 reads. impersonate="chrome" in curl_cffi 0.13.0, which resolves to its chrome136 profile, produced exactly the JA4 of Chrome for Testing 148. It did not match Chrome 155, which sends one more extension and three more signature schemes.
Does a proxy change my TLS fingerprint?
Not a tunnelling proxy. An HTTP CONNECT tunnel forwards bytes unchanged, so the target receives the ClientHello your TLS library wrote, through any exit and on any type of address. A TLS-intercepting proxy is different, because it opens its own connection with its own ClientHello.
How can I check my own client's TLS fingerprint?
Install clienthello-probe 0.1.0 from PyPI, run the short listener printed on this page, and point your client at https://127.0.0.1:8443/. It prints the JA4 and JA3 of every connection. The client reports an error, because the listener closes after reading the ClientHello.

Sources

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
  2. RFC 8701: Applying GREASE to TLS Extensibility
  3. RFC 9110: HTTP Semantics, section 9.3.6 CONNECT
  4. IANA: TLS ExtensionType Values registry
  5. IANA: TLS SignatureScheme and Supported Groups registries
  6. FoxIO: JA4 TLS client fingerprint, technical details
  7. Salesforce: JA3, the original method
  8. Chromium blink-dev: Intent to Ship, TLS ClientHello extension permutation
  9. BoringSSL tls1.h: trust_anchors and application_settings codepoints
  10. IETF draft: TLS Trust Anchor Identifiers
  11. Go 1.24 release notes: X25519MLKEM768 enabled by default
  12. Go net/http documentation: Transport.ForceAttemptHTTP2
  13. OpenSSL 3.5 release notes: default key shares
  14. What's New in Python 3.10: ssl module defaults
  15. httpx 0.27.2 source: DEFAULT_CIPHERS and ALPN order
  16. rustls source: ClientHello extensions encoded in random order
  17. clienthello-probe on PyPI

Read this page as Markdown · Quote it freely under CC BY 4.0 with a link back.