~/patrickdoyle

What your TLS handshake gives away

last edited December 2, 2025

A 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:

  1. TLS version
  2. Cipher suites, in offered order
  3. Extensions, in offered order
  4. Supported elliptic curves
  5. 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 requests and 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.

: : :

← all posts  ·  reply by mail