curl_cffi Proxy Guide: Setup, Auth, Rotation and Async
curl_cffi gives you a browser-shaped TLS handshake. A proxy gives you a clean exit IP. Here is exactly how to wire the two together — and why the https:// prefix in your proxies dict is throwing ErrCode 35.
curl_cffi is the Python binding to a curl-impersonate fork: it reproduces a real browser's TLS/JA3 and HTTP/2 fingerprints instead of announcing itself as urllib3. That fixes one axis of blocking. The other is the exit IP, which is where a curl_cffi proxy comes in — and where the documentation gets thin. The official proxy section runs about fifteen lines, and a GitHub issue from February 2023 still ranks in the top five for this topic. This guide covers the whole surface: the proxy parameter, the requests-style dict and its real key names, proxy_auth, sessions, per-request rotation, async, SOCKS5 — and the exact error strings you will paste into a search box.
curl_cffi proxy syntax: prefer proxy= over the proxies dict
curl_cffi accepts two forms. The native one is a single proxy= string, added in v0.6.0; the proxies= dict exists for requests compatibility, and the docs recommend the single parameter unless you genuinely need different proxies per scheme. Internally they collapse to the same thing — proxy="..." becomes {"all": "..."} — and both work on the module helpers, on Session, on AsyncSession and on individual requests.
# pip install curl_cffi --upgrade (Python 3.10+ since v0.14)
import curl_cffi
PROXY = "http://USER:PASS@gate.quantumproxies.io:PORT"
# Native form — one string, applies to every scheme
r = curl_cffi.get(
"https://tls.browserleaks.com/json",
impersonate="chrome",
proxy=PROXY,
timeout=30,
)
print(r.status_code, r.json()["ja3n_hash"])
# requests-compatible form
r = curl_cffi.get(
"https://httpbin.org/ip",
impersonate="chrome",
proxies={"http": PROXY, "https": PROXY},
timeout=30,
)
print(r.json()) # {'origin': '<proxy exit IP>'}
Four things about that dict are worth knowing, because none of them are obvious from the README:
- The valid keys are
all,http,https,wsandwss.allis the catch-all; the websocket keys matter only for the WebSocket client. - Per-host keys work too.
https://api.example.comorall://example.comroutes just that host through a given proxy — useful for sending one difficult domain through residential IPs and leaving the rest direct. - You cannot pass both.
proxy=plusproxies=on the same call raisesTypeError: Cannot specify both 'proxy' and 'proxies', and the same check runs at session level. - Environment variables are honoured —
http_proxy,https_proxy,ws_proxy,wss_proxy. Passtrust_env=Falseto aSessionwhen a corporate variable hijacks your scraper.
One note on imports: since v0.10.0 the package is callable directly (curl_cffi.get, curl_cffi.Session). Older tutorials use from curl_cffi import requests, which still works but reads badly next to the real requests library — and explains why half the snippets online look like a different project.
The https:// trap: ErrCode 35 and WRONG_VERSION_NUMBER
This single mistake generates more curl_cffi proxy questions than everything else combined. Issue #6 in the project tracker — opened and closed on the same day in February 2023 — still ranks on page one, because the error it produces reads like a TLS bug rather than a config typo:
# WRONG: this asks curl to open a TLS connection *to the proxy itself*
proxies = {"https": "https://USER:PASS@gate.quantumproxies.io:PORT"}
# Failed to perform, ErrCode: 35, Reason:
# 'error:100000f7:SSL routines:OPENSSL_internal:WRONG_VERSION_NUMBER'
# RIGHT: plain HTTP CONNECT, then the TLS tunnel runs through to the target
proxies = {"https": "http://USER:PASS@gate.quantumproxies.io:PORT"}
# Or skip the dict entirely
proxy = "http://USER:PASS@gate.quantumproxies.io:PORT"
The key names the protocol of the target; the value names how you reach the proxy. A normal HTTPS-over-HTTP proxy takes a plain-text CONNECT, then tunnels your encrypted traffic through untouched — so the proxy URL starts with http:// even when every URL you fetch is HTTPS. HTTPS-over-HTTPS proxies exist but are rare and must be explicitly supported by the gateway. Requests words the same failure far more helpfully — your proxy appears to only use HTTP and not HTTPS — which is why an identical config can look like a curl_cffi-specific bug. Recent versions do warn and link issue #6, but it is only a warning: the request still fails.
Authentication: URL credentials or proxy_auth
Authenticated gateways accept the usual embedded form, http://USER:PASS@host:port, with the usual catch: an unescaped @, : or / in the password splits the URL in the wrong place and produces an auth failure that looks like a dead proxy. curl_cffi offers an escape hatch requests does not — a proxy_auth tuple passed to libcurl as separate username and password options, so no encoding is involved at all.
import curl_cffi
from urllib.parse import quote
# Option A — credentials in the URL, password URL-encoded
pw = quote("p@ss:word", safe="")
r = curl_cffi.get(
"https://httpbin.org/ip",
proxy=f"http://USER:{pw}@gate.quantumproxies.io:PORT",
impersonate="chrome",
timeout=30,
)
# Option B — keep credentials out of the URL entirely
r = curl_cffi.get(
"https://httpbin.org/ip",
proxy="http://gate.quantumproxies.io:PORT",
proxy_auth=("USER", "PASS"),
impersonate="chrome",
timeout=30,
)
print(r.json())
A third option removes this class of bug entirely: IP whitelisting. Every QuantumProxies residential plan lets you authorise your server's IP instead of sending user:pass, so the proxy URL becomes a bare http://gate.quantumproxies.io:PORT — nothing to encode, no secret in your source tree. If the credentials themselves are being rejected, our guide to every cause of 407 Proxy Authentication Required covers the rest.

Sessions, cookies and the credential-reuse detail
A Session holds cookies, connection pooling and your defaults in one place, which is what you want for anything multi-step. Set impersonate and proxy once and every request inherits them:
from curl_cffi import Session
with Session(
impersonate="chrome",
proxy="http://USER-session-a1b2:PASS@gate.quantumproxies.io:PORT",
timeout=30,
retry=3,
) as s:
s.get("https://httpbin.org/cookies/set/foo/bar")
r = s.get("https://httpbin.org/cookies")
print(r.json(), s.cookies.get_dict())
Two behaviours deserve a callout. First, whenever a proxy is configured curl_cffi switches on libcurl's proxy-credential-no-reuse option: a new connection is forced when the proxy username changes, and the TLS session cache is keyed on the proxy address, so a previous exit IP cannot leak into a later request through a reused session. If you encode sticky-session IDs in the username, as most rotating gateways do, you get that isolation for free. Second, retry (an int, or a RetryStrategy from curl_cffi.requests with delay, backoff and jitter) only re-runs on a transport exception. It does not retry a 403 or 429 the way urllib3's status_forcelist does — that loop is still yours to write. The compatibility docs list retries as unsupported, which is stale: the parameter landed in v0.15.0.
Point curl_cffi at a rotating residential gateway
Per-request rotation and async
curl_cffi advertises asyncio with proxy rotation on each request, and that is literal: a proxy= argument on an individual call overrides whatever the session holds. You rarely need a proxy list to exploit it — a rotating gateway assigns a fresh exit server-side on every connection, so one endpoint plus concurrency is already rotation. Where you do want control (one stable IP per worker, per account, per cart) put a session token in the username and let the gateway pin that exit.
import asyncio
from curl_cffi import AsyncSession
GATE = "gate.quantumproxies.io:PORT"
URLS = ["https://httpbin.org/ip"] * 20
async def fetch(session, url, worker):
# one sticky exit IP per worker; drop the -session- suffix for full rotation
proxy = f"http://USER-session-{worker}:PASS@{GATE}"
r = await session.get(url, proxy=proxy, timeout=30)
return r.status_code, r.json()["origin"]
async def main():
async with AsyncSession(impersonate="chrome", max_clients=10) as s:
return await asyncio.gather(
*(fetch(s, u, i % 5) for i, u in enumerate(URLS))
)
for status, ip in asyncio.run(main()):
print(status, ip)
max_clients caps the concurrent curl handles in the pool (10 by default), so it is your real concurrency dial — bolting a semaphore onto an unbounded gather is the usual mistake. The same sizing logic applies to any async client, which we covered in async Python scraping with httpx and aiohttp. Whether to rotate per request or pin a session depends on whether the site tracks state across requests; the trade-offs are in sticky sessions vs rotating proxies.

SOCKS5, HTTP/3 and the safety switches
SOCKS needs no extra install — libcurl is compiled in, so unlike requests there is no [socks] extra to remember. Use socks5h://USER:PASS@gate.quantumproxies.io:PORT: the h pushes DNS resolution to the proxy, which stops leaks from your own network and resolves geo-fenced hostnames from the exit's location. curl_cffi detects the socks prefix and skips the HTTP tunnelling flag, since the SOCKS protocol handles that itself. Every plan here exposes HTTP and SOCKS5 endpoints on the same gateway, so switching is a scheme swap rather than a new order.
- HTTP/3 over a proxy arrived in v0.15.0 alongside http/3 fingerprints, but it needs a SOCKS5 server that speaks UDP, not a plain HTTP gateway. Niche until your target rewards QUIC.
- SSRF hardening. The same release carried an advisory: if you fetch URLs supplied by other people, redirects can be walked into your internal network. Set
allow_redirects="safe", or turn redirects off. - Debugging. v0.15 shipped a CLI:
curl-cffi get tls.browserleaks.com/json --impersonate chrometells you in one line whether impersonation is landing, before you blame the proxy.
When curl_cffi plus a proxy is enough
More often than people expect. If the target serves JSON from an internal API or server-rendered HTML, and the only obstacle is a fingerprint check, a matched handshake plus a residential exit clears it at a fraction of a browser's cost and latency. The project's FAQ is blunt about the ceiling: fingerprints are one factor among several, alongside IP quality, request rate and JavaScript checks, and higher protection tiers need both a better proxy pool and real browser automation. When impersonation is configured correctly and you are still blocked, the remaining variable is nearly always the exit IP — isolating that in five minutes is the subject of curl_cffi vs requests. If you would rather run neither, the Scraper API handles fingerprints, proxies and optional JS rendering behind one call.
Frequently asked questions
How do I use a proxy with curl_cffi?
Pass proxy="http://USER:PASS@host:port" to any request method, session or async session. The requests-style proxies={"http": ..., "https": ...} dict also works, but the project recommends the single parameter unless you need different proxies per scheme. Passing both raises a TypeError.
Why does curl_cffi throw ErrCode 35 WRONG_VERSION_NUMBER?
Because the proxy URL starts with https://. A standard proxy expects a plain-text CONNECT request and then tunnels your TLS through; an https:// prefix makes curl try to TLS-handshake with the proxy itself, which answers in plain HTTP. Change the value to http:// — the https key refers to the target, not the hop.
Does curl_cffi support SOCKS5 proxies?
Yes, natively — libcurl is bundled, so there is no optional extra to install. Use the socks5h:// scheme so hostnames are resolved by the proxy rather than your machine. SOCKS4, SOCKS4a and plain socks5:// are also accepted; the library skips HTTP tunnelling for any proxy whose scheme starts with socks.
Can curl_cffi rotate proxies on every request?
Yes. A proxy= argument on an individual call overrides the session default, including inside an AsyncSession, which is what the README means by asyncio with per-request rotation. With a rotating gateway you often need no logic at all: the same endpoint hands out a different exit IP per connection.
Can curl_cffi bypass Cloudflare?
Sometimes. It removes the TLS and HTTP/2 fingerprint tell, which is enough for basic protection levels. It cannot execute JavaScript challenges, solve Turnstile, or repair a datacenter IP that a reputation database has already flagged. Treat impersonation as one of three requirements, not the answer.
The whole configuration is smaller than its reputation: one proxy string, impersonate set once on the session, a timeout on every call, and credentials either URL-encoded or passed as a proxy_auth tuple. Get those right and the remaining variable is IP quality — a perfect Chrome handshake from a flagged datacenter address is still a flagged datacenter address. Our breakdown of JA3 and JA4 fingerprinting explains why the two checks are independent.