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.
On this page 13 sections
- What changed since the September study
- Method
- What each client declared
- JA3 against JA4 on repeat connections
- Firefox 151 does not shuffle and sends no GREASE
- What Chrome 155 added
- The impersonation profile matches Chrome 148, not Chrome 155
- Post-quantum key shares pushed ClientHellos past 1,400 bytes
- Two library notes
- Go’s default client offers HTTP/2
- Node 18 and Node 24 differ on every count
- What this means behind a proxy
- Reproducing this
- Download the data
- 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.
| 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
| 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
| 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.
- tls-clienthello-census-update.csv, 108 rows, 30 columns
- 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
itod. 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?
Is JA3 still useful?
Why does JA4 stay the same when JA3 changes?
Does Firefox randomise its TLS extension order?
Can curl_cffi make a client look like Chrome?
Does a proxy change my TLS fingerprint?
How can I check my own client's TLS fingerprint?
Sources
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 8701: Applying GREASE to TLS Extensibility
- RFC 9110: HTTP Semantics, section 9.3.6 CONNECT
- IANA: TLS ExtensionType Values registry
- IANA: TLS SignatureScheme and Supported Groups registries
- FoxIO: JA4 TLS client fingerprint, technical details
- Salesforce: JA3, the original method
- Chromium blink-dev: Intent to Ship, TLS ClientHello extension permutation
- BoringSSL tls1.h: trust_anchors and application_settings codepoints
- IETF draft: TLS Trust Anchor Identifiers
- Go 1.24 release notes: X25519MLKEM768 enabled by default
- Go net/http documentation: Transport.ForceAttemptHTTP2
- OpenSSL 3.5 release notes: default key shares
- What's New in Python 3.10: ssl module defaults
- httpx 0.27.2 source: DEFAULT_CIPHERS and ALPN order
- rustls source: ClientHello extensions encoded in random order
- clienthello-probe on PyPI