curl_cffi Guide: TLS Fingerprinting, JA3/JA4 and Browser Impersonation in Python

Developer guide Β· Last reviewed 20 October 2026 Β· 17 min read

curl_cffi is a Python HTTP client that can make its TLS and HTTP/2 handshakes look like those of a real Chrome, Safari or Firefox build. That matters because many sites now classify clients before they read a single header: the TLS ClientHello and the first HTTP/2 frames are summarized as JA3, JA4 and “Akamai” fingerprints, and a plain Python client is easy to tell apart. This guide explains what those fingerprints contain, how curl_cffi’s browser impersonation works, and how to use it in practice: sessions, async concurrency, proxies, fingerprint checks and the limits you will hit.

The short version

Install curl_cffi (Python 3.10+), pass impersonate="chrome" and let the library send the matching headers. Reuse a Session or AsyncSession, keep one browser target per session, don’t override the User-Agent with a different browser’s string, and check the result against a JSON echo endpoint such as tls.browserleaks.com/json. Route traffic through a proxy with proxy= and proxy_auth=; use sticky sessions for multi-step flows and rotation for independent requests. curl_cffi does not run JavaScript, so pages that require a JS challenge still need a real browser.

Scope. Fingerprinting exists for legitimate reasons: fraud prevention, account protection and bot management. This guide is for collecting public data, testing your own sites and reproducing what real users see, within each site’s terms, robots rules and rate limits. If a site offers an official API, evaluate it first. Matching a browser’s handshake does not grant permission to access anything.

Key takeaways

  • The TLS ClientHello and the opening HTTP/2 frames identify your HTTP client before any header or cookie is read.
  • JA3 hashes the ClientHello fields in the order sent; JA4 sorts ciphers and extensions, and adds the TLS version, SNI and ALPN in a readable prefix.
  • curl_cffi wraps a patched curl (curl-impersonate) that reproduces browser TLS and HTTP/2 handshakes; impersonate="chrome" is the main switch.
  • Consistency beats cleverness: one browser target per session, its own default headers, and no random JA3 strings.
  • AsyncSession caps concurrency with max_clients (default 10); align it with your semaphore.
  • Proxies take proxy= or proxies= (not both) plus proxy_auth=(user, password).
  • No JavaScript runs in curl_cffi. JS challenges and browser-fingerprint checks need a real browser.

What curl_cffi Is

A requests-style client on top of a browser-shaped curl

curl_cffi is a Python binding, built with cffi, for a fork of curl-impersonate maintained by the same author (lexiforest). It exposes two layers: a low-level Curl object that maps to libcurl options, and a high-level API that mimics requests, with curl_cffi.get(), Session, AsyncSession and WebSocket support.

Its distinguishing feature is the impersonate parameter: set it to a browser target and the library sends that browser’s TLS ClientHello, HTTP/2 settings and default headers. It is MIT licensed and ships pre-compiled wheels for Linux, macOS and Windows. The current PyPI release at the time of writing is 0.16.3; since v0.14 it requires Python 3.10 or newer.

You need it when a site treats a Python client differently from a browser even though the URL, headers and IP are the same. Often the difference is the handshake.

What a TLS ClientHello Reveals

The first message says which software you run

Every HTTPS connection starts with a TLS handshake, and the client speaks first. Its ClientHello lists what it supports, and those lists are not mandated by the RFCs: each TLS library and each browser picks its own combination and order. Browser vendors ship fixed combinations per version, so the ClientHello works as a software signature.

ClientHello fieldWhat it containsWhy it differs between clients
TLS versionLegacy version plus the supported_versions extensionOlder stacks may top out at TLS 1.2
Cipher suitesOrdered list of cipher IDsOpenSSL, BoringSSL and NSS default to different lists
ExtensionsIDs and order: SNI, ALPN, key share, GREASE and othersBrowsers add extensions plain libraries lack; Chrome permutes their order
Supported groupsKey-exchange curves, including post-quantum hybridsNew groups appear in browsers first
Signature algorithmsAccepted signature schemes, in orderLibrary defaults vary
ALPNApplication protocols offered, such as h2 and http/1.1An HTTP/1.1-only client offers no h2

The handshake is only the first layer. A typical request passes through several observation points before any page content is involved:

Figure 1 β€” where a server can fingerprint a client
TCP connect
   |
   v
TLS ClientHello ----------> JA3 / JA3N / JA4  (ciphers, extensions, ALPN)
   |
   v
HTTP/2 preface
SETTINGS, WINDOW_UPDATE ---> "Akamai" HTTP/2 fingerprint
HEADERS (pseudo-header order)
   |
   v
HTTP headers ---------------> User-Agent, header order, client hints
   |
   v
HTML + JavaScript ----------> JS / browser fingerprint (browser only)
   |
   v
Behavior over time ---------> rate, navigation pattern, IP reputation

curl_cffi addresses the TLS, HTTP/2 and default-header layers. It does nothing for the JavaScript layer, and nothing for request rate or IP reputation. Our explainer on how DataDome detects automated traffic shows how a commercial bot-management system combines these signals.

JA3 vs JA4 and JA4+

Two ways to summarize one handshake

JA3 (Salesforce)

JA3 was published by Salesforce engineers. It concatenates five ClientHello fields, as decimal values in the order the client sent them, then takes an MD5 hash:

Example 1 β€” JA3 string format
TLSVersion,Ciphers,Extensions,SupportedGroups,ECPointFormats

771,4865-4866-4867-49195-49196,0-11-10-35-16-5,29-23-24,0
 |        |                         |            |      |
 |        cipher IDs                extension IDs groups point formats
 TLS version (771 = 0x0303)

JA3 has a practical weakness. Chrome started randomizing the order of its TLS extensions in version 110, so a real Chrome produces a different JA3 hash on almost every connection. The common workaround is JA3N, which sorts the extension list before hashing. Echo services such as browserleaks report both ja3_hash and ja3n_hash.

JA4 (FoxIO)

JA4, from FoxIO, is designed to survive that permutation. Per FoxIO’s specification, a JA4 value has three parts, joined by underscores:

  • a: a readable prefix with the transport (t TCP, q QUIC, d DTLS), the TLS version (taken from supported_versions when present), d or i for whether SNI is present, a two-digit cipher count, a two-digit extension count, and the first and last characters of the first ALPN value (h2, or h1 for http/1.1).
  • b: a truncated SHA-256 of the cipher list, sorted.
  • c: a truncated SHA-256 of the sorted extension list (excluding SNI and ALPN, which are already in part a), followed by the signature algorithms in their original order.

GREASE values are ignored throughout. An example from FoxIO’s documentation is t13d1516h2_8daaf6152771_b186095e22b6: TLS 1.3 over TCP, SNI present, 15 ciphers, 16 extensions, ALPN h2.

AspectJA3JA4
AuthorSalesforceFoxIO
Extension orderAs sent (JA3N sorts)Sorted
ALPNNot includedFirst ALPN value in the prefix
TLS versionLegacy version fieldHighest in supported_versions
FormatRaw text plus MD5Readable prefix plus two truncated SHA-256 hashes
QUICNoYes (q prefix)

JA4 is the TLS-client member of a larger family FoxIO calls JA4+, which includes JA4S (server response), JA4H (HTTP client), JA4X (certificates), JA4T (TCP), JA4SSH and others. Licensing differs: FoxIO publishes JA4 itself under BSD 3-Clause, while the other JA4+ methods are under the FoxIO License, which restricts monetization.

The curl_cffi docs are openly skeptical of JA4 for impersonation work. Because the b and c parts are hashes, you can’t read which cipher or extension changed, so the library doesn’t accept JA4 as input. It reproduces the whole ClientHello instead, and the JA4 follows from that. For debugging, compare ja3n_text and the raw ja4_r field, not the hashes.

HTTP/2 Fingerprints and the Akamai String

The second handshake most clients forget

After TLS, an HTTP/2 client sends a connection preface, a SETTINGS frame, usually a WINDOW_UPDATE and then a HEADERS frame. Browsers choose distinctive values for all of them. Akamai researchers proposed a compact text format for these values, now commonly called the Akamai HTTP/2 fingerprint. It has four pipe-separated parts:

Example 2 β€” the Akamai HTTP/2 fingerprint format
SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADER_ORDER

1:65536;4:131072;5:16384|12517377|3:0:0:201|m,p,a,s

SETTINGS        id:value pairs (1 = HEADER_TABLE_SIZE, 4 = INITIAL_WINDOW_SIZE, ...)
WINDOW_UPDATE   increment in the first WINDOW_UPDATE frame (0 if none)
PRIORITY        stream:exclusive:depends_on:weight tuples, if any
PSEUDO_HEADERS  order of :method, :path, :authority, :scheme (m, p, a, s)

The example comes from the curl_cffi documentation. Chrome, Firefox and Safari send different SETTINGS values and pseudo-header orders, so a server can check whether the HTTP/2 layer agrees with the TLS layer and the User-Agent. A mismatch is often a stronger signal than either value alone.

HTTP/3 moves this to QUIC transport parameters. curl_cffi added HTTP/3 fingerprints and UDP proxy support in v0.15; select it with http_version="v3" (with fallback) or "v3only".

Why Plain requests Stands Out

Honest clients are easy to recognize

requests is built on urllib3 and Python’s ssl module, so its ClientHello is whatever the linked OpenSSL (or LibreSSL) offers by default, plus urllib3’s settings. That handshake matches no browser. It also speaks HTTP/1.1 only, so there is no HTTP/2 fingerprint at all, and its ALPN offers http/1.1. On top of that it sends User-Agent: python-requests/x.y unless you override it.

Swapping in a Chrome User-Agent makes this worse: the header claims Chrome while the handshake says otherwise. httpx can speak HTTP/2 (with the h2 extra), but its TLS and HTTP/2 settings are still Python’s and the h2 library’s, not a browser’s.

You can see the difference in a few lines. On our test machine, requests produced a JA4 whose ALPN part read h1 and an empty akamai_text, while curl_cffi produced an h2 JA4 and a Chrome-style HTTP/2 string.

Example 3 β€” compare requests and curl_cffi fingerprints (Python)
import requests
import curl_cffi

URL = "https://tls.browserleaks.com/json"
FIELDS = ("user_agent", "ja4", "ja3n_hash", "akamai_text")


def summary(data):
    return {field: data.get(field) for field in FIELDS}


plain = requests.get(URL, timeout=20).json()
impersonated = curl_cffi.get(URL, impersonate="chrome", timeout=20).json()

print("requests :", summary(plain))
print("curl_cffi:", summary(impersonated))

Exact values depend on your OS, OpenSSL build and library versions, so run it on the machine that will run your jobs.

curl-impersonate: Where curl_cffi Comes From

A patched curl that shakes hands like a browser

curl-impersonate started as lwthiker’s project in 2022: a special build of curl whose TLS and HTTP/2 handshakes match real browsers. The approach, described in its README, was to compile curl against the TLS library a browser uses (BoringSSL for Chrome, NSS for Firefox in the original), change how curl configures TLS extensions, add missing extensions, change curl’s HTTP/2 settings and run it with browser-like ciphers, curves and headers.

The lexiforest fork is the active line today. It adds newer browser features such as Encrypted Client Hello and X25519MLKEM key exchange, control over extension order and GREASE, one binary for Chrome, Safari and Firefox, and HTTP/3 with QUIC fingerprints. curl_cffi is its Python binding. If you already use curl from the shell, our guide to curl with a proxy covers the flags that carry over.

Install and a First Impersonated Request

One parameter does most of the work
Example 4 β€” install (shell)
python3 --version          # needs 3.10 or newer
pip install --upgrade curl_cffi

The top-level curl_cffi.get() mirrors requests.get(). Add impersonate and print what the echo endpoint saw:

Example 5 β€” a basic impersonated GET (Python)
import curl_cffi

r = curl_cffi.get(
    "https://tls.browserleaks.com/json",
    impersonate="chrome",
    timeout=20,
)
data = r.json()

print(r.status_code)
print(data["user_agent"])    # Chrome UA supplied by the target profile
print(data["ja4"])           # should start with t13d...h2
print(data["akamai_text"])   # Chrome-style HTTP/2 SETTINGS string

Since v0.15 the package also installs a curl-cffi command, handy for checking a URL before writing code. Its --impersonate flag defaults to chrome, and it accepts --proxy:

Example 6 β€” the curl-cffi CLI (shell)
curl-cffi get tls.browserleaks.com/json --impersonate chrome
curl-cffi get tls.browserleaks.com/json --impersonate safari
curl-cffi list      # fingerprints available on this machine

Choosing an Impersonate Target

Aliases follow upgrades; pinned versions don’t

Targets come in two forms. Aliases (chrome, edge, firefox, safari, safari_ios, chrome_android) resolve to the newest profile bundled with your installed version. In curl_cffi 0.16.3, chrome resolves to chrome150, safari to safari2601 and firefox to firefox147. Pinned targets such as chrome146, chrome131_android, safari184_ios, firefox144 or tor145 stay fixed.

Versions are only added when a browser’s fingerprint changes, so gaps are normal; the docs suggest using the previous target with your own headers for a skipped version. A suffix a, as in chrome133a, marks an alternative fingerprint seen during browser A/B testing.

Use an alias if you upgrade the package regularly; pin a version when you need reproducible results in tests or monitoring. Avoid very old targets: the docs point out that obsolete versions have little real market share, so they stand out.

For non-browser clients, such as reproducing your own mobile app’s HTTP stack in a test, curl_cffi accepts custom ja3=, akamai= and extra_fp= values. Copy them from a packet capture rather than generating them; valid ClientHellos have constraints such as TLS 1.3 ciphers first.

Sessions, Cookies and Headers

Reuse connections and keep state together

A Session keeps cookies, default parameters and the underlying curl handle between requests, so connections are reused. Set impersonate once on the session rather than on every call:

Example 7 β€” Session with cookies (Python)
import curl_cffi

with curl_cffi.Session(impersonate="chrome", timeout=20) as s:
    # httpbin sets a cookie and redirects
    s.get("https://httpbin.org/cookies/set/session_hint/abc123")
    print(s.cookies.get_dict())      # {'session_hint': 'abc123'}

    r = s.get("https://httpbin.org/cookies")
    print(r.json())                   # {'cookies': {'session_hint': 'abc123'}}

    # Add headers a page would send; keep the profile's User-Agent
    r = s.get(
        "https://httpbin.org/headers",
        headers={"Accept-Language": "en-US,en;q=0.9"},
    )
    print(r.json()["headers"].get("User-Agent"))

When impersonate is set and default_headers is left at True, curl_cffi adds the target’s default headers (User-Agent, Accept, client hints and so on) in the browser’s order, and any header you pass replaces the default of the same name. Set default_headers=False only if you intend to supply the full, correctly ordered set yourself.

AsyncSession and Bounded Concurrency

max_clients is the real limit

AsyncSession has the same request API with await. Internally it keeps a pool of curl handles sized by max_clients, which defaults to 10. Requests beyond that wait for a free handle, so the pool already limits in-flight requests. A semaphore is still worth having because it bounds the work you start (parsing, retries, logging), not just the sockets. Keep the two numbers equal:

Example 8 β€” AsyncSession with a semaphore (Python)
import asyncio

import curl_cffi
from curl_cffi.requests.exceptions import RequestException

CONCURRENCY = 10


async def fetch(session, semaphore, url):
    async with semaphore:
        try:
            r = await session.get(url)
            return url, r.status_code, len(r.content)
        except RequestException as exc:
            return url, None, type(exc).__name__


async def main(urls):
    semaphore = asyncio.Semaphore(CONCURRENCY)
    async with curl_cffi.AsyncSession(
        impersonate="chrome",
        max_clients=CONCURRENCY,
        timeout=30,
    ) as session:
        results = await asyncio.gather(
            *(fetch(session, semaphore, url) for url in urls)
        )
    for row in results:
        print(row)


urls = [f"https://httpbin.org/anything/{i}" for i in range(50)]
asyncio.run(main(urls))

For large job sets, swap gather() for a bounded asyncio.Queue and a fixed pool of workers, as described in our aiohttp proxy guide. The backpressure and retry patterns there apply unchanged; only the client differs.

Proxies: Rotating vs Sticky with ProxyEmpire

The fingerprint says what you are; the IP says where you are

curl_cffi accepts either proxy="http://host:port" for all traffic or a proxies dict keyed by scheme (http, https, all, ws, wss). Passing both raises a TypeError. Credentials can go in the URL or, more safely, in proxy_auth=(username, password). HTTP and SOCKS proxies are supported; for HTTPS targets through an HTTP proxy, curl_cffi opens a CONNECT tunnel, so use http:// for the proxy URL, not https://.

With ProxyEmpire, the gateway is v2.proxyempire.io:5000 and authentication is by username and password. Targeting and session behavior are encoded in the username, which you copy from Proxy Manager → Connection Builder in the dashboard. Pick Same IP for a sticky session or a new IP on every request for rotation (custom rotation times up to 2 hours are also available). Before writing Python, a quick shell check confirms the credentials:

Example 9 β€” test the gateway (shell)
curl -x http://USERNAME:[email protected]:5000 https://api.ipify.org

Then compare the two modes from Python. Keep both usernames in environment variables:

Example 10 β€” rotating vs sticky through ProxyEmpire (Python)
import os

import curl_cffi

PROXY = "http://v2.proxyempire.io:5000"
PASSWORD = os.environ["PE_PASSWORD"]
ROTATING_USER = os.environ["PE_ROTATING_USER"]  # Connection Builder, new IP per request
STICKY_USER = os.environ["PE_STICKY_USER"]      # Connection Builder, Same IP


def exit_ips(username, n=3):
    ips = []
    with curl_cffi.Session(
        impersonate="chrome",
        proxy=PROXY,
        proxy_auth=(username, PASSWORD),
        timeout=30,
    ) as s:
        for _ in range(n):
            ips.append(s.get("https://api.ipify.org").text)
    return ips


print("rotating:", exit_ips(ROTATING_USER))
print("sticky:  ", exit_ips(STICKY_USER))

Run this before you build on assumptions. HTTP clients reuse connections inside a session, and that can affect how often you see a new exit IP in rotating mode. Measure it for your own setup.

One sticky session per logical task

Multi-step flows (search, paginate, open a product, check availability) should keep one exit IP and one cookie jar from start to finish. ProxyEmpire can export many sessions at once as a proxy list in host:port:user:pass format, each line its own sticky session. curl_cffi accepts proxy and proxy_auth per request, and it forces a new connection when the proxy username changes, so one AsyncSession can serve several sticky identities without sharing tunnels:

Example 11 β€” sticky sessions from an exported list, async (Python)
import asyncio

import curl_cffi


def load_sessions(path):
    sessions = []
    with open(path) as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            host, port, user, password = line.split(":", 3)
            sessions.append((f"http://{host}:{port}", (user, password)))
    return sessions


async def journey(session, proxy, auth, urls):
    cookies = curl_cffi.Cookies()   # one cookie jar per journey
    statuses = []
    for url in urls:
        r = await session.get(
            url,
            proxy=proxy,
            proxy_auth=auth,
            cookies=cookies,
            discard_cookies=True,   # keep the shared session jar clean
        )
        cookies.update(r.cookies)
        statuses.append(r.status_code)
        await asyncio.sleep(1.0)    # pace multi-step flows
    return statuses


async def main():
    sticky = load_sessions("proxyempire_sessions.txt")
    journeys = [
        ["https://example.com/", "https://example.com/?page=2"],
        ["https://example.org/", "https://example.org/?page=2"],
    ]
    async with curl_cffi.AsyncSession(
        impersonate="chrome",
        max_clients=len(journeys),
        timeout=30,
    ) as session:
        results = await asyncio.gather(*(
            journey(session, proxy, auth, urls)
            for (proxy, auth), urls in zip(sticky, journeys)
        ))
    print(results)


asyncio.run(main())

Keeping the cookie jar per journey matters. By default every response also updates the session-level jar, which would mix cookies from different identities; discard_cookies=True stops that while r.cookies still carries what the server set. For background on choosing between the two modes, see sticky vs rotating proxies; ProxyEmpire’s own sticky session docs cover the dashboard settings.

SOCKS5

Because the proxy URL goes straight to libcurl, curl’s scheme rules apply: socks5:// resolves DNS locally, socks5h:// lets the proxy resolve it. ProxyEmpire supports HTTP, HTTPS and SOCKS5 on the same gateway. Our SOCKS vs HTTP proxy comparison explains when each one fits.

HTTP/3 and proxies. HTTP/3 runs over QUIC on UDP, while an HTTP CONNECT tunnel carries TCP. Through a standard proxy gateway, expect HTTP/2 over TCP unless you have confirmed that your proxy path carries UDP.

Checking Your Fingerprint

Measure, don’t assume

The curl_cffi docs name Wireshark as the most reliable check and list several JSON echo services for a quicker one, including tls.browserleaks.com/json, tls.peet.ws/api/all and the project’s own fp.impersonate.pro. The browserleaks endpoint returns user_agent, ja4 (plus ja4_r, ja4_o and ja4_ro raw and original-order variants), ja3_hash, ja3_text, ja3n_hash, ja3n_text, akamai_hash and akamai_text.

A useful habit is to check through the same proxy your jobs use, and compare against a real browser on the same OS:

Example 12 β€” fingerprint check through the proxy (Python)
import os

import curl_cffi

ECHO = "https://tls.browserleaks.com/json"
PROXY = "http://v2.proxyempire.io:5000"
AUTH = (os.environ["PE_STICKY_USER"], os.environ["PE_PASSWORD"])

with curl_cffi.Session(
    impersonate="chrome", proxy=PROXY, proxy_auth=AUTH, timeout=30
) as s:
    fp = s.get(ECHO).json()
    ip = s.get("https://api.ipify.org").text

print("exit IP    :", ip)
print("user agent :", fp["user_agent"])
print("ja4        :", fp["ja4"])
print("ja3n_hash  :", fp["ja3n_hash"])
print("akamai     :", fp["akamai_text"])

# Open the same URL in the Chrome version you impersonate and compare
# ja4, ja3n_hash and akamai_text. ja3_hash will differ on every
# Chrome connection because of extension permutation.

An HTTP proxy that tunnels with CONNECT doesn’t alter the TLS handshake, because TLS runs end to end between your client and the target. If the fingerprint changes when you add a proxy, something on the path is terminating TLS, and that deserves investigation.

Consistency Rules

Mismatches are louder than defaults

Servers rarely check a single value. They check whether values agree. The most common self-inflicted mistake is mixing layers:

Example 13 β€” inconsistent vs consistent (Python)
import curl_cffi

url = "https://tls.browserleaks.com/json"

# Inconsistent: Chrome TLS and HTTP/2, Firefox User-Agent
bad = curl_cffi.get(
    url,
    impersonate="chrome",
    headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0"},
)

# Consistent: the profile supplies its own UA and header order;
# add only headers a real page load would carry
good = curl_cffi.get(
    url,
    impersonate="chrome",
    headers={"Accept-Language": "de-DE,de;q=0.9,en;q=0.8"},
)
One target per sessionDon’t switch browser profiles mid-session or on cookies issued to another profile.
Matching headersKeep the profile’s User-Agent. If you change the OS in the UA, update client hints such as sec-ch-ua-platform too.
Don’t combine ja3 with impersonatecurl_cffi warns when a ja3 string alters an impersonated profile.
No random fingerprintsEach browser version has a fixed fingerprint; invented ones match nothing real.
Location agreesAccept-Language and the proxy country should tell the same story.
Sticky IP for stateful flowsA cookie issued to one IP that returns from another can trigger re-checks.

One detail from the docs: the bundled desktop profiles carry macOS User-Agents. TLS fingerprints are generally the same across desktop OSes, so to present as Windows you update the User-Agent and related headers.

requests vs httpx vs curl_cffi vs a Real Browser

Pick the cheapest tool that is consistent enough
ClientTLS / HTTP/2 handshakeAsyncRuns JavaScript
requestsPython TLS stack; HTTP/1.1 onlyNoNo
httpxPython TLS stack; HTTP/2 via the h2 libraryYesNo
curl_cffiBrowser profiles for TLS and HTTP/2; HTTP/3 availableYesNo
Real browser (Playwright, Selenium)Genuine browser handshakeVia the automation APIYes

Cost runs the other way. curl_cffi’s README describes it as faster than requests and httpx and on par with aiohttp and pycurl. A browser loads scripts, images and fonts and needs far more CPU and memory per page; our headless browsers guide covers that trade-off. A practical order: the site’s API or a plain client first, curl_cffi when the handshake is what’s failing, a browser only for pages that need JavaScript. Our overview of Python web scraping with residential proxies covers the requests, httpx, Scrapy and Playwright side; Scrapy users will find community curl_cffi download handlers listed in its README.

API Differences, Retries and Errors

requests-like, not requests-identical

Most requests code ports by changing the import, but a few differences cause surprises:

Topicrequestscurl_cffi
Default timeoutNone (waits forever)30 seconds
Proxy argumentproxies= dictproxy= string or proxies= dict, not both
Proxy credentialsIn the proxy URLURL or proxy_auth=(user, pass)
File uploadsfiles=files= raises; use multipart= with CurlMime
RetriesVia urllib3 adaptersretry= int or RetryStrategy
HTTP version1.1http_version="v1", "v2", "v3" and others
Exceptionsrequests.exceptionscurl_cffi.requests.exceptions, also subclasses of CurlError

Native retries take a RetryStrategy with a count, base delay, jitter and linear or exponential backoff. They retry when a request raises, so status codes only trigger a retry if you also set raise_for_status=True. Classify the rest yourself:

Example 14 β€” retries, timeouts and error classes (Python)
import curl_cffi
from curl_cffi.requests import RetryStrategy
from curl_cffi.requests.exceptions import (
    HTTPError,
    ProxyError,
    RequestException,
    Timeout,
)

retry = RetryStrategy(count=3, delay=1.0, jitter=0.5, backoff="exponential")

with curl_cffi.Session(
    impersonate="chrome",
    retry=retry,
    timeout=(10, 30),          # connect, read
) as s:
    try:
        r = s.get("https://example.com/")
        r.raise_for_status()
        print(r.status_code, len(r.text))
    except ProxyError as exc:
        print("proxy problem, check credentials and gateway:", exc)
    except Timeout as exc:
        print("timed out:", exc)
    except HTTPError as exc:
        print("HTTP error status:", exc)
    except RequestException as exc:
        print("other request failure:", exc)

A 407 means the proxy rejected your credentials, so retrying won’t help. A 403 or 429 from the target means slow down or reconsider, not rotate forever. Our proxy error codes reference lists the rest.

For streaming APIs, Session.ws_connect() opens WebSockets through the same session, so the handshake uses the same impersonation, proxies, cookies and headers as HTTP requests. AsyncSession has an async equivalent.

Limits: What curl_cffi Can’t Do

A handshake is not a browser

curl_cffi makes the network layer look like a browser. It doesn’t make your program one.

  • No JavaScript. The docs are explicit that curl_cffi can’t change JavaScript fingerprints, because there is no JS runtime. Pages that render content client-side, or that require a script-based check before serving content, need Playwright, Selenium or another real browser. See why modern web automation runs on Chromium.
  • The handshake is one signal of many. The project’s own FAQ notes that TLS and HTTP/2 are only some of the factors bot-management services weigh, alongside IP quality, request rate and JavaScript fingerprints. A perfect ClientHello sent 50 times a second still looks automated.
  • Profiles age. Browsers update every few weeks. Upgrade curl_cffi (or run curl-cffi update, available since v0.15.1) so your profile stays close to what real users run.
  • Behavior is yours. Pacing, concurrency per host, honoring robots rules and stopping when a site says no are application decisions. No library makes them for you.

Within those limits, pair curl_cffi with sensible pacing and an IP type that fits the job: rotating residential for broad public-data collection, mobile for carrier networks, or static residential when a long-lived session needs the same IP for days.

Frequently Asked Questions

curl_cffi and TLS fingerprints, in short
What is curl_cffi used for?

curl_cffi is a Python HTTP client built on curl-impersonate. Its main use is sending requests whose TLS ClientHello, HTTP/2 settings and default headers match a real browser such as Chrome, Safari or Firefox, through a requests-like API with sync, async and WebSocket support.

What is the difference between JA3 and JA4 fingerprints?

JA3, from Salesforce, joins the TLS version, ciphers, extensions, groups and point formats in the order sent and hashes them with MD5, so Chrome’s extension permutation changes it on each connection. JA4, from FoxIO, sorts ciphers and extensions, adds the transport, TLS version, SNI and first ALPN value in a readable prefix, and uses truncated SHA-256 hashes.

Which impersonate values does curl_cffi support?

Aliases such as chrome, edge, firefox, safari, safari_ios and chrome_android resolve to the newest bundled profile (in 0.16.3, chrome is chrome150). Pinned targets such as chrome146, safari184_ios or firefox144 stay fixed. Run curl-cffi list to see what your install has.

How do I use a proxy with curl_cffi?

Pass proxy="http://host:port" or a proxies dict (not both), and credentials with proxy_auth=(username, password). For ProxyEmpire, the gateway is v2.proxyempire.io:5000 and you copy the username for rotating or sticky sessions from the Connection Builder.

Should I change the User-Agent when impersonating?

Usually not. The impersonate profile supplies a User-Agent and headers that match its TLS and HTTP/2 fingerprint. Sending a different browser’s User-Agent creates an obvious mismatch. If you change the OS in the User-Agent, update related client hints as well.

Does curl_cffi work with asyncio?

Yes. AsyncSession provides awaitable requests and keeps a pool of curl handles sized by max_clients, which defaults to 10. Align it with any semaphore you use to bound concurrency.

Can curl_cffi handle JavaScript challenges?

No. curl_cffi has no JavaScript runtime and can’t change JavaScript fingerprints. Pages that need scripts to run before they serve content require a real browser such as Playwright or Selenium.

Pair curl_cffi with clean residential and mobile IPs

One gateway for rotating or sticky residential, mobile and datacenter proxies over HTTP, HTTPS and SOCKS5, with country, city and ISP targeting and session lists you can export in one click.

ProxyEmpire proxy types and prices

ProxyEmpire

Proxies for data collection at scale.

Residential, mobile, ISP and datacenter proxies for scraping, ad verification, SEO monitoring and multi-account work. Choose from 30M+ ethically sourced residential IPs and 4M+ 4G/5G mobile IPs in 170+ countries, target by country, city, ZIP, ISP or carrier, and connect over HTTP(S) or SOCKS5 with 99.9% uptime. Try residential and mobile proxies for $1.97.

🏘️ Rotating Residential Proxies
  • 30M+ Premium Residential IPs
  • Β 170+ Countries
    Every residential IP in our network corresponds to an actual desktop device with a precise geographical location. Our residential proxies are fast and reliable, with 99.9% uptime, and work for a wide range of use cases. You can use country, region, city, ZIP, ISP, ASN and OS fingerprint targeting for our rotating residential proxies, at no extra charge.

See our Rotating Residential Proxies

πŸ“ Static Residential Proxies
  • 24 Countries
    Buy a dedicated static residential IP from one of the 24 countries that we offer proxies in. Keep the same IP for a month or longer, while benefiting from their fast speed and stability.

See our Static Residential Proxies

πŸ“³ Rotating Mobile Proxies
  • 4M+ Premium Mobile IPs
  • Β 170+ Countries
    Access millions of clean mobile IPs with precise targeting by country, region, city, ZIP, mobile carrier, ASN and OS fingerprint, at no extra charge. Get far fewer IP blocks and CAPTCHAs with our 4G and 5G proxies.

See our Mobile Proxies

πŸ“± Dedicated Mobile Proxies
  • 4 Countries
  • 50+ Locations
    Get your own dedicated mobile proxy in one of our supported locations, with unlimited bandwidth and unlimited IP changes on demand. A great choice when you need a small number of mobile IPs and a lot of proxy bandwidth.

See our 4G & 5G Proxies

🌐 Rotating Datacenter Proxies
  • 197,000+ IPs Premium IPs
  • Β 62 Countries
    On a budget and need to do some simple scraping tasks? Our datacenter proxies are the perfect fit! Get started with as little as $2

See our Datacenter Proxies

♾️ Unlimited Residential Proxies
  • 1M+ Daily Residential IPs
  • 85+ Countries
    Our unlimited residential proxies bill by connection speed, not gigabytes: 100 to 1000 Mbit with unlimited bandwidth, on daily, weekly or monthly plans. Ask us for a 1-hour trial at 50 Mbit before you pick a plan.

See our Unlimited Residential Proxies

proxy locations

30M+ rotating IPs

99.9% uptime - high speed

99.9% uptime.

dedicated support team

24/7 Dedicated Support.

fair price

Fair Pricing.

🏠 Residential Proxies Rotating / Static / Unlimited
πŸ“± Mobile Proxies Rotating and Dedicated
πŸ–₯️ Datacenter Proxies Rotating
🌍 IP Pool 30M+ residential + 4M+ mobile IPs
πŸ“Ά Uptime 99.9% Β· Live status
πŸ’³ Payment Card Β· PayPal Β· Crypto Β· Bank transfer
πŸ’¬ Support 24/7 live chat Β· [email protected]