Skip to content
Anti-bot systems

Browsers vary every TLS handshake. Libraries never do.

Twelve connections from one Chromium browser produced twelve different JA3 hashes. curl and Go produced one each, every time.

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.

Frequently asked questions

Does a TLS fingerprint identify my browser?
Less than it used to. We captured twelve consecutive connections from one Chromium browser and got twelve different extension orders, and therefore twelve different JA3-style hashes. The extension set was identical each time; only the order changed. Chrome shuffles it deliberately so a fixed-order hash stops working as an identifier.
Is JA3 still useful?
For libraries, yes, and very much so. For Chrome, a JA3 value matches one connection and then never again, because the hash is taken over the ordered extension list. Newer schemes that sort the extensions before hashing exist precisely because of this.
Why does my scraper get blocked when its headers are perfect?
Because the TLS handshake is sent before any header, and it is produced by your TLS library rather than by your code. In our captures every library had a fixed, distinctive handshake: Go offered 13 cipher suites, curl 49, wget 68. The classification can finish before a single header arrives.
Will a better proxy fix a TLS fingerprint?
No. The handshake is generated by the client and forwarded unchanged, so it is identical through any proxy, from any country, on any category of address. Only replacing the TLS implementation or driving a real browser engine changes it.
What is GREASE and why does it matter?
GREASE inserts deliberately invalid values into the cipher and extension lists so that servers which cannot tolerate unknown values fail early and visibly. Chromium sends it. None of the eight libraries we tested did. That makes its absence a cheap single-bit signal that cannot be forged at the header layer.
Which library is closest to a browser?
None of them, on this measure. Node sent the most extensions of the libraries at eleven, against the browser's fifteen, and it was missing both encrypted client hello and the Chrome application-settings extension. Closeness on count does not help when specific extensions are absent.

Sources

  1. RFC 8446: TLS 1.3, the ClientHello message
  2. IANA: TLS ExtensionType Values registry
  3. RFC 8701: GREASE for TLS
  4. RFC 7301: Application-Layer Protocol Negotiation

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