Tools

curl_cffi: beat TLS fingerprinting without a browser

Your headers look perfect and the site still blocks you on the first request. That's your TLS handshake talking, and curl_cffi is the fastest way to make it lie convincingly, no browser required.

Disclosure: some links here are affiliate links. If you sign up through them we may earn a commission at no extra cost to you. We only recommend tools we'd use ourselves. Hire a Clawd is our own service.

Here's a weird one: your scraper sends the exact right User-Agent, the exact right headers in the exact right order, and the site still 403s you before your code ever sees a response body. You didn't mess up the HTTP layer. The problem happened earlier, in bytes your requests or httpx code never touches directly.

That's TLS fingerprinting, and it gets checked before anyone reads your headers. curl_cffi fixes exactly that layer: it makes a Python client's handshake look like a real Chrome or Safari install, without launching an actual browser. No rendering engine, no GPU process, just a socket telling a very convincing lie. Here's how to install it, which impersonate targets to reach for, a proxied request you can run right now, and the one thing it flatly can't do.

Quick scope note: everything below assumes public pages, product listings, prices, search results, not credentialed data or anything behind a paywall. If you're unsure where that line sits legally, we cover it in is web scraping legal.

Why your scraper's handshake gives you away

Every HTTPS connection opens with a TLS ClientHello, sent before a single HTTP header exists. That packet lists the cipher suites your client supports, in the order it prefers them, plus TLS extensions, supported curves, ALPN protocols, and a few other fields. Hash all of that together and you get a fingerprint, JA3 in the old scheme, JA4 for what most vendors actually score in 2026.

Chrome's ClientHello looks different from Firefox's, which looks different from Python's default ssl module, which is what requests, httpx, and plain urllib all hand your connection to underneath. That default OpenSSL shape has been cataloged for years. Cloudflare, DataDome, Akamai, all of them keep a list, and a scraper that shows up with a textbook Python handshake gets flagged before your carefully spoofed User-Agent header is even parsed.

This is why swapping headers alone never fixes a hard block. Claim to be Chrome 124 all day; if the handshake underneath says Python plus OpenSSL, the mismatch is the tell, and it's a more reliable signal than any header. The header lied. The handshake didn't. For the full rundown of every layer anti-bot vendors score, not just this one, see our bot detection checklist.

What curl_cffi actually does

curl_cffi is a Python wrapper around a patched build of libcurl, one that ships pre-built TLS ClientHello templates lifted from real browser releases. Set impersonate="chrome124" and it's not just changing a string somewhere, it's sending the actual cipher order, extension order, and ALPN list Chrome 124 sends, then matching the HTTP/2 SETTINGS frame and pseudo-header order on top. The JA4 hash that comes out the other side looks like a browser because, at the wire level, it basically is one.

It ships a requests-shaped API on purpose. get, post, Session, a proxies dict, a cookies jar, all familiar if you've written a Python scraper before. The difference is entirely underneath, in a handshake you never see.

And because compiled C is doing the actual work, it's fast. No browser to boot, no page to render, no JavaScript engine spinning up in the background. On a single core it'll push far more requests per second than anything driving a real browser, which starts to matter a lot once you're pulling thousands of pages instead of a handful.

Install it

One line, and it ships prebuilt wheels for Linux, macOS, and Windows, so you're not compiling libcurl yourself:

pip install curl_cffi

That's genuinely it. No system-level curl-impersonate build, no fighting OpenSSL versions. Async support is already in the box too, curl_cffi.requests.AsyncSession works with asyncio the same way aiohttp does.

Picking an impersonate target

The impersonate argument is a string naming a browser and version, and it decides which handshake template gets used. A few you'll actually reach for:

TargetMimicsWhen to use it
chromeLatest stable Chrome curl_cffi supportsDefault choice, matches the most common real-world traffic
chrome124A pinned Chrome versionA stable fingerprint across a long crawl, immune to a library upgrade quietly changing the alias underneath you
safari18_0Safari on macOSTargets that favor Safari traffic, or to diversify away from an all-Chrome crawl
edge101Chromium EdgeSame coverage as Chrome targets with a different UA mix

Firefox targets exist too, and the project keeps adding more of them, but Chrome and Safari are still where the coverage runs deepest. Check curl_cffi's own README for the current full list before you lock one in, browsers ship new TLS behavior a few times a year and the library keeps pace, which means whatever list you memorize today drifts.

Whichever target you pick, let it set your User-Agent header too, curl_cffi does this automatically to match. Override it by hand and you've rebuilt the exact mismatch you were trying to avoid: a Chrome 124 handshake wearing a Chrome 90 label.

Need something more exact than a named target? Pass a raw ja3= string and build a custom fingerprint from a capture. Most people never need this. Start with impersonate and only reach for it if one specific target keeps getting scored anyway.

A runnable proxied GET

Here's the whole thing end to end, browser-shaped handshake and a proxy in one call:

from curl_cffi import requests

resp = requests.get(
    "https://example.com/api/products",
    impersonate="chrome124",
    proxies={
        "http": "http://user:pass@gate.example-proxy.com:8080",
        "https": "http://user:pass@gate.example-proxy.com:8080",
    },
    timeout=15,
)

print(resp.status_code)
print(resp.headers.get("server"))
print(resp.text[:300])

The proxies dict is the exact shape requests uses, so if you're migrating an existing scraper this is close to a drop-in swap: change the import, add impersonate, keep everything else. Point it at a site that's currently 403ing your old requests client and, for anything doing passive TLS checks only, watch the block disappear.

Recommended tool

The handshake isn't the only fingerprint

A flawless JA4 match from a flagged datacenter IP still gets you blocked. IP reputation is scored on its own axis, checked before curl_cffi's handshake even gets evaluated. Pair it with a rotating residential pool from our proxy guide so the two layers actually agree with each other.

See our proxy pick →

Affiliate link, at no extra cost to you.

Rotating that pool per request instead of holding one sticky gateway follows the same pattern we walk through in rotating residential proxies in Python. curl_cffi's proxies argument slots straight into either setup.

Sessions, cookies, and reused connections

For anything beyond one throwaway request, use a Session. It keeps your impersonation target, cookie jar, and connection pool consistent across every call, which matters, because switching handshake shape mid-flow is its own red flag:

from curl_cffi import requests

with requests.Session(impersonate="chrome124") as session:
    session.get("https://example.com/")
    login = session.post(
        "https://example.com/api/login",
        json={"user": "demo", "pass": "demo"},
    )
    print(session.cookies.get("session_id"))

    orders = session.get("https://example.com/api/orders")
    print(orders.status_code, len(orders.json()))

Everything on that session shares one cookie jar and one fingerprint, and the connection gets reused where the server allows it. That's what a real browser tab looks like from the outside, and it's a big part of why session reuse reads as more trustworthy than a fresh handshake on every request.

Running a lot of these at once? Swap in AsyncSession and drive it with asyncio.gather, same API, non-blocking underneath:

import asyncio
from curl_cffi.requests import AsyncSession

async def fetch_all(urls):
    async with AsyncSession(impersonate="chrome124") as s:
        return await asyncio.gather(*[s.get(u) for u in urls])

That's a much lighter way to get concurrency than spinning up multiple browser contexts, since there's no rendering pipeline attached to any of it.

Still a lot of infrastructure to run yourself

Picking the right target, keeping proxies healthy, and noticing the moment a site flips on a JS challenge before your success rate quietly craters: none of that is a one-time setup. It's ongoing maintenance. Hire a Clawd is a personal AI agent that runs the scraping stack for you around the clock and hands you clean data instead of a pile of scripts to babysit.

See plans from $49/mo →

The honest limit: zero JavaScript

Here's the part worth saying plainly: curl_cffi runs no JavaScript. None. It's a TLS and HTTP client wearing a very good disguise, not a browser, and that distinction matters the moment a site's defense stops being passive.

If a target only checks your TLS/JA4 fingerprint, header set, and maybe IP reputation, curl_cffi clears all of it without breaking a sweat. But some sites go further: a "checking your browser" interstitial that needs JavaScript to run a proof-of-work before it hands you a clearance cookie, or a Turnstile widget that needs an actual browser environment to pass its checks. curl_cffi can't touch either one. There's no JS engine in there to run the challenge, so you get the challenge page back, forever, no matter how perfect your handshake is.

The tell is usually a 200 status code with the wrong body: you asked for JSON and got an HTML page with a spinner, or a script tag loading from a challenge platform. That's your signal to stop tuning impersonate targets. You've hit a wall this library was never built to solve, and reaching for a real browser like Camoufox is the actual fix. We cover exactly when that switch is worth making in the Cloudflare bypass guide.

Where curl_cffi fits in your stack

Think of it as the fast, cheap first pass. Most of a crawl, honestly, is pages that only do passive fingerprinting: product listings, search results, public profiles, pricing pages. curl_cffi handles all of that at a speed and cost per request a browser can't touch, because there's no rendering pipeline to pay for on every single page.

Reserve the browser for pages that actually need one: a login flow gated by a JS challenge, a Turnstile-gated checkout, a single-page app that only renders its content client-side after hydration. Route those to Camoufox, or a stealth-patched Playwright setup, and let curl_cffi handle everything else. Mixing both in one pipeline, cheap client for the bulk of the crawl, real browser for the hard pages, gets you more data per dollar than defaulting to a browser for everything.

And no matter which client sends the request, the IP underneath it gets scored on its own axis. A datacenter proxy behind a perfect JA4 fingerprint is still a datacenter proxy. Get both layers right, or neither buys you much.

Skip the setup

The Anti-Detect Scraping Starter Kit

A ready-to-run curl_cffi plus residential proxy Python template, session handling and target selection already wired up, with the Camoufox fallback ready for the pages that throw a JS challenge. One setup guide, both layers covered.

$19 one-time

Get the kit →

FAQ

Does curl_cffi actually bypass Cloudflare?

For the passive layer, often on the first try. For a live JavaScript challenge or a visible Turnstile widget, no, and it never will, there's no JS engine in the library to run either one. Pair it with a real browser for the subset of pages that need one; see our Cloudflare guide for the full layered approach.

Is curl_cffi faster than Playwright or Camoufox?

Considerably. There's no browser process to launch, no page to render, no JavaScript engine idling in the background. For pages that only need the TLS layer solved, curl_cffi pushes far more requests through a single machine than anything driving an actual browser. That gap disappears the moment you actually need JavaScript, at which point speed stops being the relevant metric.

How is this different from cloudscraper?

Different mechanism entirely. cloudscraper and the older cfscrape-style tools tried to solve Cloudflare's old JavaScript challenge with Python code that mimicked the expected computation, and that approach mostly stopped working once Cloudflare moved on. curl_cffi doesn't try to solve a challenge at all. It changes your TLS and HTTP/2 handshake so you don't trigger the passive check in the first place. Different layer, and one that's still actively maintained.

Do I still need proxies if my TLS fingerprint is perfect?

Yes. IP reputation gets scored independently of your handshake, and a datacenter IP gets flagged whether your JA4 hash is flawless or not. See our residential proxy guide for picking a pool that actually holds up.