← All posts

TLS Fingerprinting and Bot Detection: Why Your Scraper Gets Flagged Before It Sends a Request

tls fingerprinting and bot detection

Quick answer: When your client opens an HTTPS connection, the very first message (the TLS ClientHello) exposes a distinctive pattern of cipher suites, extensions, and their ordering. Anti-bot systems hash that pattern into a fingerprint (JA3 and its successors) and compare it to known browsers. python-requests, Go's net/http, and plain curl produce fingerprints no real browser emits, so they can be flagged before sending a single byte of your HTTP request. The fix is a client that impersonates a real browser's TLS stack, like curl_cffi.

This is a defensive and educational walkthrough of a mechanism most people don't know exists. Understanding it explains a lot of otherwise baffling "why am I blocked when my headers are perfect" moments.

The Handshake Happens Before HTTP

Here's the part that trips everyone up. You spend hours perfecting your User-Agent and headers, and you're still blocked instantly. That's because HTTP headers travel inside the encrypted tunnel, and the fingerprinting happens while building the tunnel, before any header is sent.

A TLS handshake starts with the client sending a ClientHello that advertises what it supports:

  • The list of cipher suites, in preference order
  • The list of TLS extensions, in the order they appear
  • Supported elliptic curves and point formats
  • The TLS version and ALPN protocols

None of this is secret and none of it is malicious to inspect; it's a normal part of TLS. But every client library builds its ClientHello differently, and those differences are stable and distinctive. A server can read them the instant the connection opens.

JA3 and What Comes After

JA3 is the fingerprint that made this technique popular. It concatenates specific ClientHello fields into a string and MD5-hashes it into a short signature like e7d705a3286e19ea42f587b344ee6865.

The key insight: a given browser version on a given platform produces a consistent JA3 hash, and python-requests produces a completely different one. Anti-bot vendors keep databases mapping hashes to clients. Your request arrives, they hash the ClientHello, they look it up, and "Python/OpenSSL" is a bright red flag no header can hide.

JA3 has a weakness that spawned successors: browsers began randomizing extension order, which changes the hash run to run. So detection moved to JA4 and similar schemes that are robust to that shuffling by sorting fields before hashing. The arms race continued; the principle didn't change. Your TLS stack is identifiable.

Why python-requests Loses Instantly

python-requests sits on OpenSSL with Python's defaults. Chrome uses BoringSSL (Google's OpenSSL fork) with a specific, tuned configuration. These produce fundamentally different ClientHellos:

  • Different cipher suite lists and ordering
  • Different extension sets (Chrome sends things like GREASE values and specific TLS 1.3 settings that a default OpenSSL client doesn't arrange the same way)
  • Different ALPN and curve preferences

So a server comparing your fingerprint against "real Chrome" sees an obvious mismatch. It doesn't matter that your User-Agent says Chrome; the TLS layer already said "some Python thing" a full round-trip earlier. Claiming to be Chrome in a header while your handshake says otherwise is arguably a stronger bot signal than being honest.

Browser-Impersonation Clients

The fix is a client that reproduces a real browser's TLS stack byte for byte. In Python the standard tool is curl_cffi, which binds to a build of curl patched to mimic browser handshakes:

from curl_cffi import requests

# Impersonate a specific Chrome build's TLS + HTTP/2 fingerprint
resp = requests.get(
    "https://example.com",
    impersonate="chrome124",
)
print(resp.status_code, len(resp.text))

That impersonate="chrome124" flag makes the ClientHello match that Chrome version's fingerprint, so the JA3/JA4 lookup returns "Chrome," not "Python." Equivalents exist in other ecosystems (utls in Go is the well-known one).

The difference in practice is stark. The same request that eats an instant 403 with python-requests often sails through with curl_cffi, no proxy required, because you removed the disqualifying signal instead of hiding it.

HTTP/2 Fingerprinting: The Second Layer

Fixing TLS isn't the finish line, because there's a parallel fingerprint one layer up. When a client speaks HTTP/2, it sends SETTINGS frames, a specific header pseudo-order, window sizes, and priority information. These also vary by client and get hashed into an HTTP/2 fingerprint (Akamai's format is the well-known one).

You can pass a browser's TLS fingerprint and still fail here if your HTTP/2 behavior screams "library." This is exactly why hand-rolling impersonation is a losing game: you have to match TLS and HTTP/2 and header order and casing, all consistently, all tracking browser releases. Good impersonation clients handle both layers together, which is the main reason to use one instead of building your own.

The Limits of Fingerprint Spoofing

Matching fingerprints gets you past the front door. It does not make you look human once you're inside, and the sophisticated defenses live inside.

  • Behavioral analysis. Request timing, navigation patterns, mouse movement, and scroll behavior. A perfect fingerprint fetching 500 pages in 500 seconds with zero mouse events is still obviously automated.
  • IP reputation. Covered in depth in our residential proxies post. A flawless Chrome fingerprint from a known datacenter IP is a contradiction detectors love.
  • JavaScript challenges. Turnstile, DataDome, and friends run code in a real browser environment and check the result. A bare HTTP client, however well-fingerprinted, can't execute it. That's when you need an actual browser.
  • Consistency checks. Your TLS fingerprint says Chrome 124 on Windows; does your User-Agent agree? Do your HTTP/2 settings? Mismatched layers are their own signal.

So the honest picture is a spectrum, not a switch. Fingerprint spoofing solves one specific failure mode. I laid out the full escalation ladder (plain client → impersonation client → headless browser → stealth browser) in curl vs headless vs stealth browsers; this post is a deep dive on why the first rung matters more than people expect.

The Defensive View

If you run a site, this is genuinely useful. TLS and HTTP/2 fingerprinting is a cheap, early, hard-to-fake signal for triaging traffic before spending compute on heavier checks. It won't catch a well-equipped scraper alone, but as one layer it's excellent, and it costs almost nothing per request.

And the scraping side: fingerprint matching is legitimate for accessing public data with a client that behaves like a browser, which is a reasonable thing to want. Keep it to public content, respect robots and rate limits, and don't use these techniques to break into things behind authentication you weren't granted. The technique is neutral; how hard you hammer and what you take is what makes it fine or not.

Where This Nets Out

If your scraper gets instant 403s with clean headers, stop editing headers. Your TLS fingerprint is the leak, and swapping to curl_cffi with browser impersonation is a one-line change that fixes a surprising share of blocks. When you also need JavaScript execution or you're facing behavioral detection, that's a browser problem, not a fingerprint problem.

Managed fetch APIs like link.sc handle the fingerprint layer (and the browser and IP layers behind it) so a single call returns clean content, which is the lazy-but-correct answer when scraping infrastructure isn't your actual product. The quickstart has a working call.


Skip the fingerprint arms race. Get a free link.sc key and fetch any URL as clean markdown with browser-grade TLS handled for you.