TLS fingerprinting is usually explained as a way to catch bots: browsers have one kind of handshake, scripts have another, and a server can tell them apart before a single header arrives. That is still true. What is no longer true is the part everyone repeats next — that the fingerprint is a stable identifier.
We captured the ClientHello from eight HTTP clients and from a Chromium browser, parsed it, and ran each one repeatedly. The browser produced a different fingerprint on every single connection. Every library produced the same one every time.
#Method
A raw TCP listener on 127.0.0.1 accepted the connection, read the first flight, and parsed the ClientHello directly: legacy version, cipher suites, extension list in the order sent, supported groups, signature algorithms, ALPN and supported versions. The handshake was never completed, because it does not need to be — the ClientHello is sent in the clear and the client has already declared everything by that point.
GREASE values were filtered out of the cipher and extension lists before counting, since they are deliberate noise rather than capability. Whether a client sends them at all is reported separately, because that is itself a signal.
Every client made a plain HTTPS request with certificate verification disabled and no other configuration. The listener and the parser are printed at the end of this page.
#What each client declared
| Client | Ciphers | Extensions | Curves | Sig algs | ALPN | GREASE |
|---|---|---|---|---|---|---|
| Chromium | 15 | 15 | — | — | h2, http/1.1 | yes |
| GNU wget | 68 | 11 | 8 | 26 | none | no |
Node 24 global fetch |
52 | 11 | 8 | 26 | http/1.1 | no |
Node 24 node:https |
52 | 10 | 8 | 26 | none | no |
| curl 8.7.1 | 49 | 6 | 4 | 11 | h2, http/1.1 | no |
| python-requests 2.32.3 | 38 | 4 | 3 | 13 | http/1.1 | no |
| python-urllib 3.9 | 38 | 4 | 3 | 13 | none | no |
Go 1.24 net/http |
13 | 9 | 5 | 12 | none | no |
Cipher count alone separates almost every client in the list. Go offers thirteen suites where wget offers sixty-eight; a server that sees thirteen has a very short list of candidates before it looks at anything else.
#Finding 1: the browser fingerprint is different on every connection
Twelve consecutive connections from the same Chromium browser, with no restart and no configuration change:
| Measure | Result across 12 connections |
|---|---|
| Distinct extension sets | 1 |
| Distinct extension orders | 12 |
| Distinct cipher lists | 1 |
| Distinct JA3-style hashes | 12 of 12 |
The same fifteen extensions, shuffled into a new order every time. Chrome began randomising extension order deliberately, precisely so that a fixed-order hash would stop working as an identifier.
This matters because JA3, the fingerprint most commonly named in articles about bot detection, is a hash over the ordered extension list. Shuffle the order and the hash changes. Our twelve captures produced twelve hashes, so a JA3 blocklist entry for this browser would match exactly one connection and then never again.
#Finding 2: every library is perfectly stable
The same test against the libraries produced the opposite result:
curl, 3 consecutive connections -> 1 distinct extension order
Go, 3 consecutive connections -> 1 distinct extension order
Identical every time. Which inverts the usual framing: TLS fingerprinting now identifies HTTP libraries reliably and browsers barely at all. The technique introduced to separate bots from browsers works cleanly on one side of that line and has been deliberately broken on the other.
For anyone writing a scraper, the practical reading is that a stable handshake is the liability. It is not that your client looks unusual; it is that it looks identical, request after request, in a way no real browser does any more.
#Finding 3: extension order is the signature, and it is unique per library
The extension identifiers below are in the order each client sent them. None of these repeats another.
| Client | Extension order |
|---|---|
| curl 8.7.1 | 43, 51, 11, 10, 13, 16 |
| python-requests | 11, 10, 13, 16 |
| python-urllib | 11, 10, 35, 13 |
| GNU wget | 65281, 11, 10, 35, 22, 23, 49, 13, 43, 45, 51 |
Node fetch |
65281, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51 |
Node node:https |
65281, 11, 10, 35, 22, 23, 13, 43, 45, 51 |
| Go 1.24 | 11, 65281, 23, 18, 5, 10, 13, 43, 51 |
Two details worth drawing out. Node’s fetch and node:https differ by exactly one extension — 16, which is ALPN — so the same runtime produces two distinguishable fingerprints depending on which API you call. And Go is the only client that does not lead with either 11 or 65281; its ordering is unlike everything else in the list.
#What those extension numbers are
The identifiers above are IANA-registered TLS ExtensionType values. Names here are taken from the registry rather than from memory, because several were renamed and the old names are still widely repeated.
| ID | Registered name | What its presence tells a server |
|---|---|---|
| 0 | server_name |
SNI: the hostname, in the clear, before encryption |
| 5 | status_request |
The client will accept a stapled OCSP response |
| 10 | supported_groups |
Which elliptic curves; the count varies widely by library |
| 11 | ec_point_formats |
Legacy, but almost every stack still sends it |
| 13 | signature_algorithms |
Another list whose length is distinctive |
| 16 | application_layer_protocol_negotiation |
ALPN: whether HTTP/2 is on the table |
| 18 | signed_certificate_timestamp |
Certificate Transparency; browsers care, most libraries do not |
| 23 | extended_main_secret |
Renamed from extended_master_secret |
| 27 | compress_certificate |
Certificate compression, common in browsers |
| 35 | session_ticket |
Session resumption support |
| 43 | supported_versions |
Where TLS 1.3 is actually negotiated |
| 45 | psk_key_exchange_modes |
Paired with 1.3 resumption |
| 51 | key_share |
The 1.3 key exchange |
| 65037 | encrypted_client_hello |
ECH. Present in the browser captures, absent from every library |
| 65281 | renegotiation_info |
Legacy renegotiation marker |
Two of these separate the browser from every library on their own. 65037, encrypted client hello, appeared in all twelve browser captures and in none of the eight library captures. 17613 is not in the registry at all — it is application settings, a Chrome extension, and its presence is close to a browser signature by itself.
The absence of 0 in our captures is an artefact of the test: we connected to an IP address rather than a hostname, and clients do not send SNI for a bare address. In normal use every client here sends it, which is why a proxy sees the destination hostname even when it cannot read the request.
#Finding 4: no library sends GREASE
GREASE is the practice of inserting deliberately invalid values into the cipher and extension lists, so that servers which cannot tolerate unknown values break early and visibly rather than years later. Chrome sends it. Firefox sends it. None of the eight libraries we tested sent it.
That makes its absence a single-bit test that costs a server nothing: no GREASE, not a mainstream browser. It cannot be forged by setting a header, and it is not affected by which proxy carries the connection.
#Finding 5: ALPN says which HTTP version you are prepared to speak
Only curl and the browser offered h2. Node’s fetch and python-requests offered http/1.1 only. wget, node:https, python-urllib and Go offered no ALPN at all.
A client that advertises no ALPN is telling the server it will not negotiate HTTP/2 in the handshake. Modern browsers always offer h2, so its absence is another cheap separator — and unlike header order, it is decided by the TLS library rather than by anything the request layer can override.
#What this means for proxies
Everything above is produced by the TLS library, before any header is sent and independent of the network path. A residential address carries the same handshake as a datacenter one. Changing country, provider or exit node changes none of it.
That is the practical answer to a question we see constantly: why a scraper with perfect headers, on a clean residential address, still gets refused. The classification finished before the headers arrived. Our companion study on what HTTP clients send by default covers the layer above this one; the two together describe everything a server knows about you before it reads a single byte of your request.
Only two things change a TLS fingerprint: replacing the TLS implementation with one that reproduces a browser’s handshake, or driving a real browser engine. Configuration at the HTTP layer cannot reach it.
#Reproducing this
The listener parses the ClientHello without completing the handshake:
import socket, struct, json, threading
GREASE = {0x0a0a,0x1a1a,0x2a2a,0x3a3a,0x4a4a,0x5a5a,0x6a6a,0x7a7a,
0x8a8a,0x9a9a,0xaaaa,0xbaba,0xcaca,0xdada,0xeaea,0xfafa}
u16 = lambda b, i: struct.unpack_from('!H', b, i)[0]
def parse(d):
if len(d) < 6 or d[0] != 0x16 or d[5] != 0x01:
return None # not a ClientHello
i = 9 + 2 + 32 # header, version, random
i += 1 + d[i] # session id
n = u16(d, i); i += 2
ciphers = [u16(d, i + k) for k in range(0, n, 2)]
i += n
i += 1 + d[i] # compression methods
exts, total = [], u16(d, i); i += 2
end = i + total
while i + 4 <= end:
t, ln = u16(d, i), u16(d, i + 2)
i += 4 + ln
if t not in GREASE:
exts.append(t)
return {'ciphers': [c for c in ciphers if c not in GREASE],
'extensions': exts,
'grease': any(c in GREASE for c in ciphers)}
Point any client at it and read what it wrote. To reproduce the randomisation result, connect from the same browser a dozen times and compare the extension order, not the extension set.
#Limits
- One machine, one date. Measured on macOS on 2 September 2026. The curl here is built against LibreSSL with SecureTransport; a curl built against OpenSSL on Linux offers a different cipher list, so the curl row describes this build rather than curl in general.
- One browser. The browser row is a Chromium build. Firefox and Safari send different sets, and this study does not measure them.
- Randomisation is a Chrome behaviour, not a law. We observed twelve orders in twelve connections. That is strong evidence of shuffling, not proof that every Chromium build in every version does it.
- No claim about detection. This measures what clients emit. What any given server does with it is outside what a listener can observe.