What your TLS handshake gives away
last edited December 2, 2025A TLS ClientHello is a fingerprint. Not in the loose sense — in the literal, JA3-hash, “I can tell you’re using Go’s net/http from a thousand miles away” sense. This post is what I tell people who ask why their “anonymous” scraper got blocked.
The fields that matter
The fingerprint is a fixed-order concatenation of:
- TLS version
- Cipher suites, in offered order
- Extensions, in offered order
- Supported elliptic curves
- Elliptic-curve point formats
Hash with MD5 (JA3) or SHA-256-truncated (JA4) and you have a stable string that travels with the connection.
Why ordering matters more than you think
Two clients can support the exact same cipher suites and still hash differently if they list them in a different preference order. Browsers, languages, and libraries each have their own canonical order baked into the binary. Changing it is harder than it sounds.
import socket, ssl
ctx = ssl.create_default_context()
# This *looks* like it changes your fingerprint...
ctx.set_ciphers("ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256")
# ...but Python's ssl module still emits extensions in OpenSSL's order.
# Your JA3 hash is determined by the OpenSSL build, not by you.
with socket.create_connection(("example.com", 443)) as raw:
with ctx.wrap_socket(raw, server_hostname="example.com") as s:
print(s.version(), s.cipher())
What you can actually do about it
Not much, in pure Python. Your options, roughly worst to best:
- Use
requestsand accept that you look like Python. - Use
curl_cffi, which speaks libcurl with browser-impersonation profiles. Decent for a while. - Use a real browser via Playwright. Slow, expensive, indistinguishable from a human until JavaScript runs.
- Stop fighting the fingerprint and use an API.
Server-side notes
If you are on the receiving end: JA4 is strictly better than JA3 (handles GREASE, sorts extensions). Cloudflare and a few CDNs will give you the fingerprint as a header for free. Logging it for a month is more useful than most threat-intel feeds I have paid for.
# nginx with the ssl_ja3 module loaded
log_format with_ja3 '$remote_addr $ssl_ja3_hash "$request" $status';
access_log /var/log/nginx/access.log with_ja3;
That is the whole thing. Treat the handshake as identifying. It is.