# 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.

```python
# pip install curl_cffi --upgrade    (Python 3.10+ since v0.14)
import curl_cffi

PROXY = "http://USER:PASS@residentialpremium.quantumproxies.io:8080"

# 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`, `ws` and `wss`.** `all` is the catch-all; the websocket keys matter only for the WebSocket client.
- **Per-host keys work too.** `https://api.example.com` or `all://example.com` routes 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=` plus `proxies=` on the same call raises `TypeError: 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`. Pass `trust_env=False` to a `Session` when 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:

```python
# WRONG: this asks curl to open a TLS connection *to the proxy itself*
proxies = {"https": "https://USER:PASS@residentialpremium.quantumproxies.io:8080"}

# 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@residentialpremium.quantumproxies.io:8080"}

# Or skip the dict entirely
proxy = "http://USER:PASS@residentialpremium.quantumproxies.io:8080"
```

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.

```python
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}@residentialpremium.quantumproxies.io:8080",
    impersonate="chrome",
    timeout=30,
)

# Option B — keep credentials out of the URL entirely
r = curl_cffi.get(
    "https://httpbin.org/ip",
    proxy="http://residentialpremium.quantumproxies.io:8080",
    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](https://quantumproxies.io/residential-proxies) lets you authorise your server's IP instead of sending user:pass, so the proxy URL becomes a bare `http://residentialboson.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](https://quantumproxies.io/blog/fix-407-proxy-authentication-required) covers the rest.

![A curl_cffi request with a Chrome-impersonating TLS handshake going through a rotating proxy gateway: a plain-text CONNECT opens a tunnel, the handshake passes through untouched, and the target sees Chrome's ClientHello and HTTP/2 settings arriving from a clean residential exit IP.](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785776553/blog/generated/qp-b10-006-diag1.png)
*The proxy tunnels the handshake rather than terminating it, so browser impersonation survives the hop and the target sees a clean exit IP.*

## 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:

```python
from curl_cffi import Session

with Session(
    impersonate="chrome",
    proxy="http://USER-sid-a1b2-ttl-30m:PASS@residentialpremium.quantumproxies.io:8080",
    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](https://quantumproxies.io/rotating-proxies)

## 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.

```python
import asyncio
from curl_cffi import AsyncSession

GATE = "residentialpremium.quantumproxies.io:8080"
URLS = ["https://httpbin.org/ip"] * 20

async def fetch(session, url, worker):
    # one sticky exit IP per worker; drop the -sid-...-ttl-30m suffix for full rotation
    proxy = f"http://USER-sid-{worker}-ttl-30m: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](https://quantumproxies.io/blog/python-async-scraping-httpx-aiohttp-proxies). 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](https://quantumproxies.io/blog/sticky-sessions-vs-rotating-proxies).

![The curl_cffi proxy mistake behind ErrCode 35: an https:// proxy URL makes curl open TLS to the proxy itself and fail with WRONG_VERSION_NUMBER, while http:// sends a plain CONNECT and tunnels TLS to the target. The card contrasts working setups with the usual mistakes, such as unescaped passwords.](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785776553/blog/generated/qp-b10-006-diag2.png)
*Most curl_cffi proxy failures are one of five things — and four of them are a single character in a string.*

## 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@residentialpremium.quantumproxies.io:1080`: 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](https://quantumproxies.io/socks5-proxies) 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 chrome` tells 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](https://quantumproxies.io/blog/curl-cffi-vs-requests). If you would rather run neither, the [Scraper API](https://quantumproxies.io/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](https://quantumproxies.io/blog/tls-ja3-fingerprinting-scraping) explains why the two checks are independent.

[Get residential IPs that match your impersonation](https://quantumproxies.io/residential-proxies)
