Bypassing TLS/JA3 fingerprinting with curl_cffi

Published: 23 July 2026 · 7 min read

TL;DR: If your scraper gets blocked even from a clean residential IP, the target is almost certainly fingerprinting your TLS handshake (JA3/JA4) and HTTP/2 frames. Python's requests and httpx produce a fingerprint no real browser has, so you are flagged before any content loads. curl_cffi impersonates a real browser's TLS signature. Use it together with a residential proxy - one fixes the IP reputation, the other fixes the fingerprint, and hard targets check both.

The block that has nothing to do with your IP

You rotate in a fresh residential IP, set a real Chrome User-Agent, copy the headers from your browser's network tab - and the target still serves a 403 or an endless CAPTCHA. Nothing about the request looks wrong. The problem is something you cannot set from the application layer: the TLS handshake itself.

When your client opens an HTTPS connection, it sends a ClientHello listing the cipher suites, extensions and curves it supports, in a specific order. That pattern is hashed into a JA3 (or the newer JA4) fingerprint. A real Chrome has one signature; Python's requests, which uses the system OpenSSL, has a completely different one. Anti-bot vendors keep a list of which fingerprints belong to real browsers and which belong to scripting libraries, and they check it during the handshake - before your headers or IP are even evaluated.

So you can have a perfect residential IP and perfect headers and still be flagged, because your TLS fingerprint says "Python".

The fix: impersonate a real browser with curl_cffi

curl_cffi is a Python binding to curl-impersonate, a build of curl patched to reproduce the exact TLS and HTTP/2 fingerprints of real browsers. Its API deliberately mirrors requests, so adopting it is mostly a one-line change.

pip install curl_cffi

A basic request that impersonates Chrome:

from curl_cffi import requests

r = requests.get("https://example.com", impersonate="chrome")
print(r.status_code, r.text[:200])

The impersonate argument is the whole point - it tells curl_cffi to send Chrome's cipher/extension ordering so the JA3 hash matches a real browser. You can pin a specific version (chrome124, chrome131, safari17_0, firefox133) or pass chrome for a recent default.

Adding a residential proxy

The fingerprint is now correct, but a hard target still watches the exit IP. Route the request through a residential proxy with the same proxies= argument you already know from requests:

from curl_cffi import requests

proxies = {"https": "http://USER:PASS@gw.roamproxy.com:41080"}

r = requests.get(
    "https://example.com",
    impersonate="chrome124",
    proxies=proxies,
)
print(r.status_code)

Now the handshake looks like Chrome and the connection exits from a real home-broadband IP. That is the combination hard targets are actually testing for. curl_cffi also accepts a SOCKS5 proxy if you prefer - use socks5://USER:PASS@gw.roamproxy.com:41080 as the value.

Avoid special characters in the proxy password. The proxy value is parsed as a URL, so a #, @, / or : in the password will break the parse. Percent-encode them (# becomes %23) or regenerate the password without them.

A session for many requests

For a real crawl, reuse a session so the connection and fingerprint are set once:

from curl_cffi import requests

session = requests.Session(impersonate="chrome124")
session.proxies = {"https": "http://USER:PASS@gw.roamproxy.com:41080"}

for url in urls:
    r = session.get(url)
    ...

To spread a large job across many exit IPs, use a rotating session so each request leaves from a different residential IP; to hold one IP across a login flow, use a sticky session. Which one you want, and how the session string controls it, is covered in what are rotating proxies.

Verify the fingerprint actually changed

Check your JA3 before and after. A fingerprint echo endpoint returns the hash the server sees:

from curl_cffi import requests

print(requests.get("https://tls.browserleaks.com/json", impersonate="chrome").json()["ja3_hash"])

Run the same call with plain requests and you will get a different hash - that difference is exactly what the target was blocking on.

Common mistakes

For the wider picture of why requests get blocked and what else to check, see how to avoid getting blocked while web scraping.

FAQ

Why do I get blocked even with a fresh residential proxy?

Because the IP is only one of the signals. Anti-bot systems such as Cloudflare, DataDome and Akamai fingerprint the TLS ClientHello (JA3/JA4) and the HTTP/2 frame order the moment the connection opens - before they see your IP reputation or your headers. Python's default stack produces a fingerprint that no real browser emits, so you are scored as automation regardless of how clean the exit IP is. A residential proxy and a browser-accurate fingerprint are two separate fixes for two separate checks; hard targets run both checks.

What exactly is a JA3 fingerprint?

JA3 is a hash of specific fields in the TLS ClientHello - the cipher suites your client offers, the extensions, the elliptic curves, in the exact order it sends them. Different HTTP clients order these differently, so the hash effectively identifies the software making the request. A real Chrome produces one JA3; Python requests (via OpenSSL) produces a completely different one. JA4 is the newer, more detailed successor. curl_cffi reproduces a real browser's ordering so the hash matches Chrome or Safari instead of "Python".

Is curl_cffi enough on its own, without a proxy?

For a lightly defended site, sometimes. For anything serious, no. Fingerprint impersonation gets you past the TLS/HTTP2 check, but if you send hundreds of requests from one datacenter IP you will still be rate-limited or blocked on IP reputation. The reliable setup is curl_cffi for the fingerprint plus a rotating residential proxy for the IP - see residential vs datacenter proxies for why the IP type matters.

requests, httpx, or curl_cffi - which should I use?

requests and httpx are fine for APIs and sites that do not fingerprint. The moment a target sits behind Cloudflare/DataDome/Akamai and blocks you despite correct headers and a good IP, switch to curl_cffi - its API mirrors requests almost exactly, so the migration is usually a one-line import change plus an impersonate= argument.

curl_cffi fixes the fingerprint; a clean exit IP fixes the reputation. Roam gives you rotating residential IPs at $2/GB and static residential at $4/IP per month over HTTP and SOCKS5, so it drops straight into the proxies= argument below. Create an account and get 300MB of free trial traffic to test the combination against your own target.