---
title: "TLS fingerprint census: 19 clients re-measured"
url: https://proxy.wiki/research/tls-clienthello-census-update/
type: Research Report
author: "proxy.wiki editorial"
published: 2026-10-21
updated: 2026-10-21
site: proxy.wiki
topics: ["Anti-bot systems"]
data_status: measured
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"
method: https://proxy.wiki/research/tls-clienthello-census-update/#method
key_figure: "24 of 24 Chrome 155 connections sent a different JA3; all 24 shared one JA4"
license: CC BY 4.0; quote freely with attribution to https://proxy.wiki/
---

# 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.

## Key takeaways

- 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.
- Firefox 151.0.2 sent one extension order on all 12 connections and no GREASE, so per-connection shuffling is a Chromium behaviour, not a browser one.
- Deno 2.9.7 (rustls) and curl_cffi 0.13.0 shuffled extension order too, so "libraries never vary" no longer holds.
- curl_cffi's chrome profile matched Chrome for Testing 148 exactly on JA4 but not Chrome 155, which adds the unregistered trust_anchors extension and three ML-DSA signature schemes.
- Clients that send the X25519MLKEM768 key share sent ClientHellos of 1,453 to 1,995 bytes, against 175 to 349 bytes for clients that do not.

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](/research/tls-clienthello-fingerprints/) 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](https://pypi.org/project/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._

| 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](/guides/proxies-in-python-requests-httpx/) 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_

| 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](/research/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_

| 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](/guides/proxies-in-nodejs/) 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](/guides/residential-vs-datacenter-proxies/) 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](/guides/403-vs-407-vs-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](/guides/how-to-use-a-proxy-with-curl/) shows the flags. Glossary background is in [JA3 fingerprint](/glossary/ja3-fingerprint/) and [TLS fingerprinting](/glossary/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](https://creativecommons.org/licenses/by/4.0/): reuse it with attribution to proxy.wiki.

- [tls-clienthello-census-update.csv](/wp-content/uploads/pw-data/tls-clienthello-census-update.csv), 108 rows, 30 columns

- [tls-clienthello-census-update.json](/wp-content/uploads/pw-data/tls-clienthello-census-update.json), the same rows plus raw bytes and run metadata

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.

## Data

- [tls-clienthello-census-update.csv](https://proxy.wiki/wp-content/uploads/pw-data/tls-clienthello-census-update.csv) (CSV, 52.9 KB, 108 rows)
- [tls-clienthello-census-update.json](https://proxy.wiki/wp-content/uploads/pw-data/tls-clienthello-census-update.json) (JSON, 437.0 KB, 108 records)

Licensed CC BY 4.0. Cite <https://proxy.wiki/research/tls-clienthello-census-update/>.

## 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](https://www.rfc-editor.org/rfc/rfc8446.html)
2. [RFC 8701: Applying GREASE to TLS Extensibility](https://www.rfc-editor.org/rfc/rfc8701.html)
3. [RFC 9110: HTTP Semantics, section 9.3.6 CONNECT](https://www.rfc-editor.org/rfc/rfc9110.html)
4. [IANA: TLS ExtensionType Values registry](https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml)
5. [IANA: TLS SignatureScheme and Supported Groups registries](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml)
6. [FoxIO: JA4 TLS client fingerprint, technical details](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md)
7. [Salesforce: JA3, the original method](https://github.com/salesforce/ja3)
8. [Chromium blink-dev: Intent to Ship, TLS ClientHello extension permutation](https://groups.google.com/a/chromium.org/g/blink-dev/c/bYZK81WxYBo/m/lKLrZ_P2BwAJ)
9. [BoringSSL tls1.h: trust_anchors and application_settings codepoints](https://boringssl.googlesource.com/boringssl/+/HEAD/include/openssl/tls1.h)
10. [IETF draft: TLS Trust Anchor Identifiers](https://datatracker.ietf.org/doc/draft-ietf-tls-trust-anchor-ids/)
11. [Go 1.24 release notes: X25519MLKEM768 enabled by default](https://go.dev/doc/go1.24)
12. [Go net/http documentation: Transport.ForceAttemptHTTP2](https://pkg.go.dev/net/http#Transport)
13. [OpenSSL 3.5 release notes: default key shares](https://openssl-library.org/news/openssl-3.5-notes/)
14. [What's New in Python 3.10: ssl module defaults](https://docs.python.org/3/whatsnew/3.10.html)
15. [httpx 0.27.2 source: DEFAULT_CIPHERS and ALPN order](https://github.com/encode/httpx/blob/0.27.2/httpx/_config.py)
16. [rustls source: ClientHello extensions encoded in random order](https://github.com/rustls/rustls/blob/main/rustls/src/msgs/client_hello.rs)
17. [clienthello-probe on PyPI](https://pypi.org/project/clienthello-probe/)
