Reading layout

Using curl_cffi to Impersonate Browsers

This walkthrough is a hands-on companion to TLS & JA3 Fingerprint Evasion in Python, narrowing the focus to one library — curl_cffi — and how its impersonate parameter makes a Python request indistinguishable at the TLS layer from Chrome or Safari.

curl_cffi impersonation outcome Top row: a default requests client presents a generic OpenSSL JA3, the WAF checks it, and returns 403. Bottom row: curl_cffi with impersonate chrome124 presents a matching Chrome JA3, passes the same WAF check, and returns 200. requestsdefault clientGeneric OpenSSL JA3no browser matchWAF check403curl_cffiimpersonate=chrome124Chrome JA3matches real buildWAF check200
A default client sends a generic JA3 and is blocked; curl_cffi replays a Chrome fingerprint and passes.

The quick answer: install curl_cffi, import its requests module, and pass impersonate="chrome124" (or another supported profile) to every call. That single argument replays the target browser's cipher suites, TLS extension order, GREASE values, and default header ordering, so the JA3 and JA4 fingerprints the server computes match a real browser instead of a stock OpenSSL client. You still supply your own explicit User-Agent and can layer proxies on top exactly as you would with the standard requests library.

Why curl_cffi Works Where requests Fails

The standard requests and httpx libraries hand TLS negotiation to the system OpenSSL, whose cipher and extension ordering no browser reproduces. curl_cffi instead binds to a build of libcurl compiled against BoringSSL — the same TLS engine Chrome uses — and ships pre-recorded ClientHello templates for popular browsers. When you name a profile, the library sends that exact handshake. Because the fingerprint is fixed by the profile and not by your header strings, the result lands in the allowlist that anti-bot vendors maintain for genuine browser builds.

Two consequences matter in practice. First, the impersonate value should track a browser version the vendor still trusts, so prefer recent profiles such as chrome124, chrome131, or safari17_0. Second, your declared User-Agent must name the same build; a chrome124 handshake under a Chrome 108 User-Agent recreates the drift you are trying to erase.

Installation and a First Impersonated Request

Install into a Python 3.10+ environment:

pip install "curl_cffi>=0.7"

The API mirrors requests, so migration is mostly a changed import plus the extra keyword:

from curl_cffi import requests

def fetch_html(url: str) -> str:
    """Fetch a page 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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = requests.get(url, headers=headers, impersonate="chrome124", timeout=20)
    response.raise_for_status()
    return response.text

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

Point the same function at a TLS inspection endpoint and the reported JA3 hash will match a real Chrome build rather than an OpenSSL client.

Adding Proxies and Persistent Sessions

TLS impersonation is independent of the proxy layer, so proxies work just as they do in the standard library. Use a Session to pool connections and persist cookies across requests, which is essential once a site issues a clearance cookie you must carry forward. This pairs naturally with Rotating Proxies and Managing IP Blocks:

from curl_cffi import requests

def build_session(proxy_url: str) -> requests.Session:
    """Create a Chrome-impersonating session routed through a proxy."""
    session = requests.Session()
    session.headers.update({
        "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",
    })
    session.proxies = {"http": proxy_url, "https": proxy_url}
    return session

def fetch_json(session: requests.Session, url: str) -> dict:
    """Fetch a JSON endpoint through the impersonating session."""
    response = session.get(url, impersonate="chrome124", timeout=20)
    response.raise_for_status()
    return response.json()

if __name__ == "__main__":
    sess = build_session("http://user:pass@proxy-host:8080")
    payload = fetch_json(sess, "https://tls.peet.ws/api/all")
    print("JA3 hash:", payload["tls"]["ja3_hash"])

Set impersonate on the session default by passing it once per request, or lift it to a wrapper so you never forget it. The proxy credentials, TLS profile, and header set now form one coherent identity.

Rotating Profiles and Comparing Fingerprints

Rotating between browser identities reduces the chance any single fingerprint accumulates a bad reputation. The demonstration below cycles through several profiles and prints the JA3 each one produces, proving the fingerprint really changes with the profile:

from curl_cffi import requests

PROFILES: list[str] = ["chrome124", "chrome131", "safari17_0"]

def fingerprint_for(profile: str) -> str:
    """Return the server-observed JA3 hash for a given impersonation profile."""
    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",
    }
    response = requests.get(
        "https://tls.peet.ws/api/all",
        headers=headers,
        impersonate=profile,
        timeout=20,
    )
    response.raise_for_status()
    return response.json()["tls"]["ja3_hash"]

if __name__ == "__main__":
    for name in PROFILES:
        print(f"{name:12s} -> {fingerprint_for(name)}")

When you rotate profiles for real traffic, rotate the matching User-Agent alongside each one so the HTTP layer stays consistent with the TLS layer.

Async Requests for Higher Throughput

For large runs, curl_cffi exposes an AsyncSession that fits the concurrency patterns in Asynchronous Scraping with asyncio and httpx. It keeps impersonation while letting you fan out hundreds of requests without a thread per connection:

import asyncio
from curl_cffi.requests import AsyncSession

async def fetch_one(session: AsyncSession, url: str) -> int:
    """Fetch one URL and return its status code."""
    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"
        ),
    }
    response = await session.get(url, headers=headers, impersonate="chrome124", timeout=20)
    return response.status_code

async def main(urls: list[str]) -> list[int]:
    """Fetch many URLs concurrently with one impersonating session."""
    async with AsyncSession() as session:
        return await asyncio.gather(*(fetch_one(session, u) for u in urls))

if __name__ == "__main__":
    targets = ["https://tls.peet.ws/api/all"] * 5
    print(asyncio.run(main(targets)))

Edge Cases and Caveats

  • Profile availability is version-locked. A profile string like chrome131 only exists if your installed curl_cffi build ships it. Upgrade the package before assuming a newer browser is available, and pin the version in your lockfile so deployments stay reproducible.
  • TLS is not the whole story. Impersonation fixes the handshake, not JavaScript challenges. If a site returns an interstitial that must execute script, you still need a browser — see Bypassing Cloudflare and Akamai Protections.
  • Header order still matters. curl_cffi sets a browser-like default header order, but if you overwrite headers wholesale you can disturb it. Update headers rather than replacing the entire mapping, and include the Sec-Fetch-* values Chrome sends.
  • Do not mix clients mid-session. Falling back to stock requests for one call inside an otherwise impersonated flow leaks an OpenSSL fingerprint and can burn the session's clearance cookie.
  • HTTP/2 fingerprints exist too. Sophisticated vendors also hash HTTP/2 frame settings. curl_cffi profiles align these as well, which is another reason to rely on a named profile rather than hand-tuning TLS alone.

Frequently Asked Questions

Does curl_cffi work with proxies? Yes. Set session.proxies or pass proxies={"https": "http://user:pass@host:port"} per request, exactly as with the standard requests library. TLS impersonation happens in the handshake and is completely independent of the proxy hop, so the two compose cleanly.

Which impersonate value should I use? Use the newest profile your installed version supports, such as chrome124 or chrome131, because anti-bot vendors increasingly distrust older browser fingerprints. Always pair the profile with a User-Agent naming the same browser build so the TLS and HTTP layers agree.

Is curl_cffi faster than a headless browser? Considerably. There is no browser process, DOM, or JavaScript engine — only a TLS handshake and an HTTP request — so it uses a fraction of the memory and CPU and scales to far higher concurrency, especially through the AsyncSession.

Can I use the same code I wrote for requests? Mostly. The API is intentionally requests-compatible, so changing the import and adding the impersonate argument covers the common cases. Session objects, proxies, cookies, and raise_for_status() behave as you expect.