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.
AsyncSessioncaps concurrency withmax_clients(default 10); align it with your semaphore.- Proxies take
proxy=orproxies=(not both) plusproxy_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 curlcurl_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 runEvery 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 field | What it contains | Why it differs between clients |
|---|---|---|
| TLS version | Legacy version plus the supported_versions extension | Older stacks may top out at TLS 1.2 |
| Cipher suites | Ordered list of cipher IDs | OpenSSL, BoringSSL and NSS default to different lists |
| Extensions | IDs and order: SNI, ALPN, key share, GREASE and others | Browsers add extensions plain libraries lack; Chrome permutes their order |
| Supported groups | Key-exchange curves, including post-quantum hybrids | New groups appear in browsers first |
| Signature algorithms | Accepted signature schemes, in order | Library defaults vary |
| ALPN | Application protocols offered, such as h2 and http/1.1 | An 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:
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 handshakeJA3 (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:
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 (
tTCP,qQUIC,dDTLS), the TLS version (taken fromsupported_versionswhen present),dorifor 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, orh1forhttp/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.
| Aspect | JA3 | JA4 |
|---|---|---|
| Author | Salesforce | FoxIO |
| Extension order | As sent (JA3N sorts) | Sorted |
| ALPN | Not included | First ALPN value in the prefix |
| TLS version | Legacy version field | Highest in supported_versions |
| Format | Raw text plus MD5 | Readable prefix plus two truncated SHA-256 hashes |
| QUIC | No | Yes (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 forgetAfter 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:
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 recognizerequests 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.
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 browsercurl-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 workpython3 --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:
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:
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’tTargets 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 togetherA 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:
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 limitAsyncSession 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:
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 arecurl_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:
curl -x http://USERNAME:[email protected]:5000 https://api.ipify.org
Then compare the two modes from Python. Keep both usernames in environment variables:
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:
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 assumeThe 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:
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 defaultsServers rarely check a single value. They check whether values agree. The most common self-inflicted mistake is mixing layers:
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"},
)
sec-ch-ua-platform too.ja3 string alters an impersonated profile.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| Client | TLS / HTTP/2 handshake | Async | Runs JavaScript |
|---|---|---|---|
| requests | Python TLS stack; HTTP/1.1 only | No | No |
| httpx | Python TLS stack; HTTP/2 via the h2 library | Yes | No |
| curl_cffi | Browser profiles for TLS and HTTP/2; HTTP/3 available | Yes | No |
| Real browser (Playwright, Selenium) | Genuine browser handshake | Via the automation API | Yes |
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-identicalMost requests code ports by changing the import, but a few differences cause surprises:
| Topic | requests | curl_cffi |
|---|---|---|
| Default timeout | None (waits forever) | 30 seconds |
| Proxy argument | proxies= dict | proxy= string or proxies= dict, not both |
| Proxy credentials | In the proxy URL | URL or proxy_auth=(user, pass) |
| File uploads | files= | files= raises; use multipart= with CurlMime |
| Retries | Via urllib3 adapters | retry= int or RetryStrategy |
| HTTP version | 1.1 | http_version="v1", "v2", "v3" and others |
| Exceptions | requests.exceptions | curl_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:
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 browsercurl_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 shortWhat 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.



