Reading layout

TLS and JA3 Fingerprint Evasion in Python

Long before a web application firewall inspects a single HTTP header, it has already watched your client negotiate a TLS connection — and that handshake alone is often enough to flag a scraper. Default Python HTTP clients built on OpenSSL advertise a cipher order and extension list that no real Chrome, Firefox, or Safari build ever sends, and modern anti-bot vendors hash those parameters into a compact fingerprint they can block instantly. This guide is part of Advanced Scraping Techniques & Anti-Bot Evasion, and it focuses on one layer of the stack: understanding how JA3, JA3S, and JA4 fingerprints are computed and how to make a Python client present the exact TLS signature of a real browser. Always confine these techniques to targets whose robots.txt and terms of service you have reviewed.

JA3 fingerprint pipeline The TLS ClientHello supplies the TLS version, cipher suites, extensions, elliptic curves and point formats. These are concatenated and hashed with MD5 into a JA3 fingerprint, compared to a fingerprint database, and the connection is allowed for a real browser or denied for a default Python client. ClientHelloTLS versionCipher suitesExtensionsElliptic curvesEC point formatsJoin fields,MD5 hashJA3 fingerprintcd08e31494f9…Compare tofingerprint DBReal browser → allowedDefault Python → denied
Fields from the TLS ClientHello are joined and hashed into a JA3 fingerprint, then matched against known browsers.

When to Use TLS Fingerprint Evasion

TLS impersonation solves a narrow but common failure mode: your request is refused before any application logic runs, regardless of headers, proxies, or cookies. Reach for it when you observe these signals:

  • You receive an immediate 403 or a challenge page even with a perfect header set and a fresh residential IP.
  • The same URL loads fine in a real browser and in curl with --tls-max, but fails from requests, httpx, or aiohttp.
  • The target sits behind Cloudflare, Akamai, DataDome, or PerimeterX — see Bypassing Cloudflare and Akamai Protections for the full WAF picture.
  • You want to keep using a lightweight HTTP client instead of a headless browser, because the data is served by a JSON or HTML endpoint rather than rendered client-side.

If the site instead detects you through JavaScript execution, canvas rendering, or the navigator.webdriver flag, TLS spoofing alone will not help — that is the domain of Browser Fingerprint & Stealth Configuration. The two layers are complementary: a stealth browser with a leaking TLS profile is still trivially fingerprinted, and a perfect TLS profile with an automated browser environment is caught by client-side checks.

Prerequisites

You need Python 3.10 or newer. Install the two impersonation libraries covered here:

pip install "curl_cffi>=0.7" tls-client

curl_cffi binds to a patched build of libcurl (BoringSSL) that can replay real browser TLS profiles, and tls-client wraps a Go TLS stack with the same goal. Neither needs a system curl install — the compiled extension ships in the wheel. Verify the install and inspect the fingerprint your default client sends:

python -c "import curl_cffi; print(curl_cffi.__version__)"

To see the raw fingerprint difference, TLS inspection endpoints such as tls.peet.ws/api/all return the JA3 and JA4 strings the server observed.

1. Why Default Python TLS Handshakes Get Flagged

When any TLS client opens a connection it sends a ClientHello message that advertises, in a specific order: the TLS version, the list of supported cipher suites, the list of extensions, the supported elliptic curves (named groups), and the elliptic-curve point formats. OpenSSL — which underlies requests, httpx, and urllib3 — has its own opinionated defaults for these lists. Chrome uses BoringSSL with a different set and, critically, a different order. Because order is part of the fingerprint, even two clients that support identical ciphers produce different hashes.

The following script shows what an anti-bot service sees. It sends an explicit User-Agent claiming to be Chrome, yet the underlying TLS layer betrays a stock Python client:

import httpx

def probe_default_fingerprint(url: str) -> dict:
    """Fetch a TLS inspection endpoint with a stock Python client."""
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
        ),
        "Accept": "application/json",
    }
    with httpx.Client(timeout=15.0) as client:
        response = client.get(url, headers=headers)
        response.raise_for_status()
        return response.json()

if __name__ == "__main__":
    data = probe_default_fingerprint("https://tls.peet.ws/api/all")
    print("JA3:", data["tls"]["ja3"])
    print("JA3 hash:", data["tls"]["ja3_hash"])
    print("JA4:", data["tls"]["ja4"])

The reported User-Agent says Chrome 124, but the JA3 hash will match no Chrome build in any fingerprint database. That mismatch — a browser user-agent riding on an OpenSSL handshake — is itself a high-confidence bot signal.

2. How a JA3 Fingerprint Is Computed

JA3 turns the variable parts of the ClientHello into a single MD5 hash. The recipe concatenates five decimal fields separated by commas, and within a field the values are separated by hyphens:

TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats

A concrete pre-hash string looks like this:

771,4865-4866-4867-49195-49199,0-23-65281-10-11-35-16-5-13,29-23-24,0

771 is TLS 1.2 in decimal; the next group is the cipher list, then the extension list, then the named groups, then the point formats. That whole string is MD5-hashed to yield the familiar 32-character JA3, for example cd08e31494f9531f560d64c695473da9. JA3S applies the same idea to the server's ServerHello, which is useful for correlating a session, and JA4 is a newer, more robust scheme that records a human-readable prefix (protocol, cipher count, extension count, ALPN) plus truncated hashes, making it resistant to the cipher-shuffling that GREASE values introduce.

You can reproduce the hash yourself to demystify it:

import hashlib

def ja3_hash(pre_hash: str) -> str:
    """Return the MD5 JA3 fingerprint for a JA3 pre-hash string."""
    return hashlib.md5(pre_hash.encode("ascii")).hexdigest()

if __name__ == "__main__":
    sample = "771,4865-4866-4867-49195-49199,0-23-65281-10-11-35-16-5-13,29-23-24,0"
    print(ja3_hash(sample))

The takeaway: to evade the check you do not attack the hash, you reproduce the input — you make Python send the same cipher and extension ordering a real browser sends, so the resulting hash lands in the allowlist.

3. Impersonating Browsers with curl_cffi

curl_cffi is the most direct fix. It exposes a requests-compatible API but routes every call through a BoringSSL-backed libcurl that can replay named browser profiles. Passing impersonate="chrome124" aligns the cipher suites, extension order, GREASE values, and default header order in one step. The dedicated walkthrough Using curl_cffi to Impersonate Browsers covers proxies and header tuning in depth; the core pattern is short:

from curl_cffi import requests

def fetch_impersonated(url: str) -> str:
    """Fetch a URL while presenting a Chrome 124 TLS fingerprint."""
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
        ),
        "Accept-Language": "en-US,en;q=0.9",
    }
    with requests.Session() as session:
        response = session.get(url, headers=headers, impersonate="chrome124", timeout=20)
        response.raise_for_status()
        return response.text

if __name__ == "__main__":
    body = fetch_impersonated("https://tls.peet.ws/api/all")
    print(body[:400])

Keep the declared User-Agent version consistent with the impersonation target: a chrome124 TLS profile paired with a Chrome 110 user-agent reintroduces the exact drift you are trying to remove. Use the newest profile your installed version supports, since WAFs progressively distrust older browser signatures.

4. Impersonating Browsers with tls-client

tls-client is an alternative that wraps uTLS, a Go library, and is handy when you want fine-grained control or a second engine for rotation. It exposes profiles by identifier and returns a session you drive much like requests:

import tls_client

def fetch_with_tls_client(url: str) -> str:
    """Fetch a URL using tls-client's Chrome 124 profile."""
    session = tls_client.Session(
        client_identifier="chrome_124",
        random_tls_extension_order=True,
    )
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
        ),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    }
    response = session.get(url, headers=headers)
    return response.text

if __name__ == "__main__":
    print(fetch_with_tls_client("https://tls.peet.ws/api/all")[:400])

The random_tls_extension_order flag mirrors the way Chrome shuffles certain extensions per connection, which frustrates naive per-value blocklists. In practice, keeping both curl_cffi and tls-client available lets you switch engines when one profile starts getting challenged.

5. Keeping the Full Signal Consistent

TLS is one signal among several the server correlates. To stay coherent, match every layer to the same browser identity:

  • Send the header set and header order Chrome sends, not just a User-Agent. Add the Sec-Ch-Ua, Sec-Fetch-*, and Accept-Language values that browser emits.
  • Route through IPs whose reputation fits a normal user. Pair impersonation with Rotating Proxies and Managing IP Blocks so a clean TLS profile is not undone by a flagged datacenter range.
  • Understand the request/response mechanics you are imitating; the fundamentals in Understanding HTTP Requests and Responses explain the header semantics WAFs score.

Performance and Scaling Considerations

TLS-impersonating clients are dramatically cheaper than headless browsers — there is no rendering engine, only a handshake and a request — so throughput is limited mainly by your network and proxy pool rather than CPU. A few practices keep large runs healthy. Reuse a Session so connections are pooled and the handshake cost is amortized across requests to the same host. Rotate impersonation profiles and proxies together, not independently, so each identity stays internally consistent. Because curl_cffi releases the GIL during network I/O, thread pools scale well; for very high concurrency it also exposes an AsyncSession that fits the patterns in Asynchronous Scraping with asyncio and httpx. Finally, cache successful responses aggressively — the fastest evasion is the request you never repeat.

Common Errors and Fixes

curl_cffi raises curl_cffi.requests.errors.RequestsError: Failed to perform, ErrCode: 35 (SSL connect error). This usually means the impersonation target is not supported by your installed version. Upgrade with pip install -U curl_cffi and pick a profile the version documents, for example chrome124 or chrome131.

Server returns 403 despite impersonation. The TLS layer is correct but another signal leaks — most often a missing Sec-Fetch-* header or a mismatched User-Agent version. Align the header set to the same browser build and confirm your proxy IP is not already flagged.

tls_client raises TLSClientException: failed to build client out of request input. The client_identifier string is wrong. Use an identifier the library ships (such as chrome_124 or safari_16_0); the underscore-and-version format differs from curl_cffi's.

ImportError or a segmentation fault on import. You installed a wheel built for a different Python or platform. Recreate the virtual environment on Python 3.10+ and reinstall so the compiled extension matches your interpreter.

Fingerprint still reads as OpenSSL on the inspection endpoint. You are calling stock requests or httpx somewhere in the path — verify the import is from curl_cffi import requests and that no retry adapter or wrapper silently falls back to the standard library.

Frequently Asked Questions

Does changing my User-Agent change my JA3 fingerprint? No. The User-Agent is an HTTP header sent after the TLS handshake completes, while JA3 is derived entirely from the ClientHello. You can send any User-Agent string and it will not alter the JA3 hash — which is exactly why a browser User-Agent on an OpenSSL handshake is such a reliable bot signal.

What is the difference between JA3 and JA4? JA3 is an MD5 hash of five ClientHello fields and is sensitive to the extension shuffling and GREASE values modern browsers introduce, which can make it noisy. JA4 is a newer scheme that records a readable prefix (TLS version, cipher and extension counts, ALPN) alongside truncated hashes, making it more stable and more descriptive. Many vendors now log both.

Can I spoof TLS fingerprints with plain requests or httpx? Not meaningfully. Both delegate TLS to the system OpenSSL, whose cipher and extension ordering you cannot reshape into a browser profile from Python. Use curl_cffi or tls-client, which ship TLS stacks designed to replay real browser handshakes.

Is TLS impersonation enough to scrape a Cloudflare-protected site? Sometimes, when the endpoint returns data directly to a well-formed HTTP request. If the site serves a JavaScript challenge or relies on client-side telemetry, you also need a stealth browser environment; see Browser Fingerprint & Stealth Configuration for that layer.