---
title: "Browsers vary every TLS handshake. Libraries never do."
url: https://proxy.wiki/research/tls-clienthello-fingerprints/
type: Research Report
author: "proxy.wiki editorial"
published: 2026-09-01
updated: 2026-09-06
site: proxy.wiki
topics: ["Anti-bot systems"]
license: CC BY 4.0 — quote freely with attribution to https://proxy.wiki/
---

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

## Key takeaways

- Chromium sent a different extension order on all 12 connections we captured, producing 12 different JA3-style hashes from one browser.
- curl and Go sent an identical order every time, so TLS fingerprinting now identifies libraries reliably and browsers barely at all.
- Cipher count alone separates most clients: Go offers 13 suites, curl 49, Node 52, wget 68.
- None of the 8 libraries tested sent GREASE. Chromium did, which makes its absence a one-bit test for "not a mainstream browser".
- Node's global fetch and node:https differ by exactly one extension, ALPN, so the same runtime yields two distinguishable fingerprints.
- A proxy changes none of this: the handshake is produced by the TLS library, before any header and independent of the network path.

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](/glossary/residential-proxy/) carries the same handshake as a datacenter one. Changing country, provider or [exit node](/glossary/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](/research/default-request-headers/) 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](https://www.rfc-editor.org/rfc/rfc8446.html)
2. [IANA: TLS ExtensionType Values registry](https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml)
3. [RFC 8701: GREASE for TLS](https://www.rfc-editor.org/rfc/rfc8701.html)
4. [RFC 7301: Application-Layer Protocol Negotiation](https://www.rfc-editor.org/rfc/rfc7301.html)
