Put the proxy in -x, exactly as with plain curl, and keep the wrapper script: curl_chrome150 -x http://user:pass@host:port https://tls.peet.ws/api/all. In Python it is one call with curl_cffi: requests.get(url, impersonate="chrome", proxy="http://user:pass@host:port"). The proxy changes the address the target sees. The wrapper changes the TLS and HTTP/2 handshake the target sees. We measured both on 2 September 2026. Through three public HTTP proxies and a SOCKS5 proxy, curl_chrome150 kept the JA4 of a real Chrome 151. Only the address changed.
Everything below comes from that run. The tools were curl-impersonate 2.2.2 with curl 8.21.0 inside, curl_cffi 0.16.3 and a fingerprint echo service. The proxies were public entries from our own list, plus two local test proxies for the password cases. Where a step needs a live proxy, pull one from our free proxy API.
Why does a proxy alone not fix a blocked request?
Every TLS connection starts with a ClientHello. RFC 8446 requires it as the first message. It lists the cipher suites and extensions the client supports, in the client's order, before any encryption starts. JA3, the method Salesforce published in 2017, hashes the TLS version, the ciphers, the extensions, the curves and the point formats of that message into one MD5. JA4, by FoxIO, sorts the extensions first. Cloudflare's bot documentation says the resulting fingerprint "acts as a stable identifier across different destination IPs, ports, and certificates".
HTTP/2 adds a second signature. RFC 9113 makes both sides send a SETTINGS frame at the start of a connection. The values are not negotiated; they describe the sender. The order of the settings, the window update and the pseudo-headers is stable per client. A proxy touches neither layer. RFC 9110 defines CONNECT as a tunnel that restricts the proxy "to blind forwarding of data, in both directions". A SOCKS proxy relays bytes the same way. The address changes, the handshake does not.
The table shows what the echo service saw from each client on 2 September 2026, without a proxy.
| client | JA4 | HTTP/2 hash | JA3 | reads as |
|---|---|---|---|---|
| curl 8.5.0 | t13d3112h2_e8f1e7e78f70_b26ce05bbdd6 | 64a832f5 | stable | curl |
| Python urllib, OpenSSL 3.0 | t13d1813h1_85036bcba153_d339722ba4af | none (HTTP/1.1) | stable | a script |
| curl-impersonate, no wrapper | t13d2713h2_b4f9224df7c1_91b187c34d40 | 52d84b11 | stable | curl |
| curl_chrome150 wrapper | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11 | new per connection | Chrome |
| curl_cffi impersonate="chrome" | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11 | new per connection | Chrome |
| real Chrome 151, Windows | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11 | new per connection | Chrome |
| curl_firefox147 wrapper | t13d1717h2_5b57614c22b0_3cbfd9057e0d | 6ea73faa | stable | Firefox |
The wrapper and the Python binding matched the real browser on JA4, on the HTTP/2 fingerprint and on the echo service's own hash. The JA3 column is the one people trip over. Chrome has randomised the order of its ClientHello extensions since version 110, per the Chrome Platform Status entry. Every Chrome-target connection in our run produced a new JA3 while JA4 stayed constant. The curl_cffi FAQ says the same: the JA3 of the binding and of a browser "may differ", by design. What is JA3 and JA4 fingerprinting has the full method. How websites detect proxies covers the other signals.
Raw curl or Python
script handshake
curl-impersonate + proxy
browser JA4 + the proxy's address
Anti-bot
both signals pass
Target
serves the page
Which curl-impersonate should I install?
There are two projects with the same name. The original, by lwthiker, published its last release, 0.6.1, on 2 March 2024. It sits on curl 8.1.1, its newest Chrome is 116, and it needs two binaries plus the NSS packages. The fork by lexiforest, which also maintains curl_cffi, calls itself "an active fork". It published 2.2.2 on 1 September 2026 on curl 8.21.0, as one binary for Chrome, Edge, Safari, Firefox and Tor. Tutorials that tell you to install libnss3 or to pull a 0.5 or 0.6 Docker tag describe the old project.
| path | what you get | proxy goes in | platforms | size and version |
|---|---|---|---|---|
| release tarball | curl-impersonate binary plus 38 wrapper scripts | -x on the wrapper | Linux (glibc and musl, x86_64, arm, riscv64), macOS, Windows, FreeBSD, Android, iOS | 10.8 MB download, 30.9 MB binary, v2.2.2 of 1 September 2026 |
| Docker image | the same binary and wrappers in a container | -x after the wrapper name in docker run | anywhere Docker runs | lexiforest/curl-impersonate 58 MB, :alpine 32 MB, tags latest, alpine and version numbers, updated 1 September 2026 |
| curl_cffi | libcurl-impersonate as a Python package with a requests-style API and a curl-cffi command | proxy= or proxies= in the call | Linux, macOS, Windows wheels, Python 3.10 or newer | 0.16.3 of 2 September 2026 |
The release binary
Download the tarball for your platform from the fork's releases page on GitHub and unpack it. The docs say the prebuilt binaries "only rely on the target system's standard runtime libraries" and may need the ca-certificates package. On Ubuntu 24.04 we used the x86_64-linux-gnu build.
curl -sSLO https://github.com/lexiforest/curl-impersonate/releases/download/v2.2.2/curl-impersonate-v2.2.2.x86_64-linux-gnu.tar.gz
mkdir curl-impersonate && tar -xzf curl-impersonate-v2.2.2.x86_64-linux-gnu.tar.gz -C curl-impersonate
cd curl-impersonate && ./curl-impersonate --version | head -1
curl 8.21.0-IMPERSONATE (Linux) libcurl/8.21.0-IMPERSONATE BoringSSL zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.7 nghttp2/1.63.0 ngtcp2/1.20.0 nghttp3/1.15.0
The folder holds 38 wrappers. They cover 17 desktop Chrome versions from 99 to 150, two Android Chrome, two Edge, eleven Safari including iOS, four Firefox up to 147, and Tor 14.5. The newest wrappers are one line, curl-impersonate --compressed --impersonate chrome150, because the fingerprint now lives inside the binary. Older ones such as curl_chrome136 spell out the ciphers, the curves, fourteen headers and the HTTP/2 settings. Reading one is a good way to see what a browser signature consists of. On macOS the docs give brew install lexiforest/tap/curl-impersonate, on Arch pacman -S curl-impersonate.
The Windows tarball ships curl-impersonate.exe and 36 .bat wrappers. It has no curl_chrome150.bat. In our run curl-impersonate.exe --compressed --impersonate chrome150 still gave the Chrome 150 JA4, and curl_chrome146.bat worked direct and through a proxy.
The Docker image
The fork's images live under lexiforest/curl-impersonate on Docker Hub. The tags are latest, alpine and version numbers such as v2.2.2; there is no tag named after a browser. The docs' example, with the proxy flag added after the wrapper name, reads as follows.
docker run --rm lexiforest/curl-impersonate:v2.2.2 curl_chrome150 -x http://user:pass@host:port https://tls.peet.ws/api/all
We read the tag list from the registry but did not run the image. Treat the line as the documented form rather than a measured one.
curl_cffi for Python
pip install curl_cffi --upgrade
curl-cffi list
The curl_cffi README states Python 3.10 as the minimum since version 0.14. It promises the install works "on Linux, macOS and Windows out of the box". curl-cffi list printed the same 38 targets as the Linux wrappers on our 0.16.3 install. curl-cffi update fetches new presets without a new package.
How do I run the wrapper through an HTTP proxy?
Step 1: use a wrapper, never the bare binary
The docs are explicit: "running the binary directly does not produce the same TLS and HTTP/2 signatures as the impersonated browsers". We measured it. The bare binary reported JA4 t13d2713h2_b4f9224df7c1_91b187c34d40 and the user agent curl/8.21.0-IMPERSONATE. curl_chrome150 gave the Chrome value. Pick the wrapper that matches the headers you send.
Step 2: add -x, with the password in the URL or in -U
The wrapper passes every flag to curl. The proxy syntax is therefore the one from our cURL proxy guide.
# proxy without a password
curl_chrome150 -x http://203.0.113.7:8080 https://tls.peet.ws/api/all
# password in the URL; @ and : inside the password become %40 and %3a
curl_chrome150 -x http://user:pa%40ss@203.0.113.7:8080 https://tls.peet.ws/api/all
# password in -U, which curl hides from process listings where it can
curl_chrome150 -x http://203.0.113.7:8080 -U user:pass https://tls.peet.ws/api/all
The curl manual sets the rules. A proxy string with no scheme or with http:// is an HTTP proxy. The port defaults to 1080 when omitted. -x overrides the https_proxy environment variable. The user and password in the string are URL-decoded. All three forms returned HTTP 200 through our local test proxy.
Step 3: read what the target saw
Send both a plain curl and the wrapper through the same proxy and compare. This capture is our own run through a public entry from our list.

fp() { python3 -c "import json,sys;d=json.load(sys.stdin);print(d['ip'], d['tls']['ja4'], d['http2']['akamai_fingerprint_hash'])"; }
curl -s -x http://103.237.102.191:11111 https://tls.peet.ws/api/all | fp
curl_chrome150 -s -x http://103.237.102.191:11111 https://tls.peet.ws/api/all | fp
103.237.102.191:48060 t13d3112h2_e8f1e7e78f70_b26ce05bbdd6 64a832f547be33249bf4d33e8a46c5dc
103.237.102.191:48072 t13d1516h2_8daaf6152771_806a8c22fdea 52d84b11737d980aef856699f885ca86
We repeated the wrapper run through three public HTTP proxies, a local HTTP proxy with a password and a local SOCKS5 proxy.
| route for curl_chrome150 | exit address seen | JA4 | HTTP/2 hash |
|---|---|---|---|
| direct | ours | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11 |
| public HTTP proxy 103.237.102.191 | the proxy | same | same |
| public HTTP proxy 116.202.172.187 | the proxy | same | same |
| public HTTP proxy 87.251.77.29 | the proxy | same | same |
| local HTTP proxy with Basic auth | ours | same | same |
| local SOCKS5 proxy with a password | ours | same | same |
| public SOCKS5 entry that terminates TLS | no page: exit 60 | not ours | not ours |
Every route negotiated HTTP/2 through the proxy. The wrapper's values never moved. The last row is the one to remember, and it gets its own section below.
Step 4: add nothing that touches TLS
The docs list the flags that change the signature: --ciphers, --curves, --no-npn, --no-alpn, --tls-max, --tls13-ciphers and every --tlsv1.x option. We added --tlsv1.3 to the chrome150 wrapper. The JA4 became t13d312h2_55b375c5d22e_6f1636e650ef. Proxy flags, -H for extra headers and -o are safe; TLS flags are not.
How do I use a SOCKS5 proxy with curl-impersonate?
Use the socks5h:// scheme so the proxy resolves the hostname. With socks5://, curl resolves the name on your machine first and sends the address. The manual describes --socks5 as "resolve the hostname locally" and --socks5-hostname as "let the proxy resolve the hostname". RFC 1928 gives the SOCKS5 request a domain-name address type for exactly this. In our run -v printed Host httpbin.org:443 was resolved. before the SOCKS connect with socks5://. With socks5h:// no such line appeared.
curl_chrome150 -x socks5h://user:pass@203.0.113.7:1080 https://tls.peet.ws/api/all
curl_chrome150 -x socks5h://203.0.113.7:1080 --proxy-user user:pass https://tls.peet.ws/api/all
Both forms returned 200 through our local SOCKS5 server. A wrong password came back in 0.02 seconds as curl: (97) User was rejected by the SOCKS5 server (1 1). No password at all gave curl: (97) No authentication method was acceptable. For a SOCKS hop in front of an HTTP proxy, the manual offers --preproxy socks5://host:1080 together with -x.
| HTTP proxy | SOCKS5 proxy | |
|---|---|---|
| scheme in -x | http:// (the default) | socks5h://, or socks5:// for local DNS |
| how HTTPS passes | CONNECT tunnel, then blind forwarding | relay after the SOCKS handshake |
| who resolves the name | the proxy | socks5h: the proxy; socks5: you |
| password | URL or -U, Basic over CONNECT | URL or --proxy-user, SOCKS username method |
| wrong password | CONNECT tunnel failed, response 407 (or 401) | (97) User was rejected by the SOCKS5 server |
| scheme pointed at the wrong port | (56) Proxy CONNECT aborted at a SOCKS port | (28) timeout at an HTTP port |
| fingerprint | unchanged | unchanged |
One warning about public SOCKS5 lists, ours included. On 2 September 2026 all 40 recent SOCKS5 entries we pulled from our list completed the SOCKS handshake and then presented a certificate that curl refused. Read on before you reach for -k.
How do I use a proxy with curl_cffi in Python?
The binding wraps the same library, so the proxy syntax is curl's. The docs prefer the single proxy argument. They also warn that "a very common mistake is to add https:// prefix to the https proxy". A requests-style proxies dict and the http_proxy and https_proxy variables work as well.
from curl_cffi import requests
r = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome",
proxy="http://user:pass@203.0.113.7:8080",
)
d = r.json()
print(d["ip"], d["tls"]["ja4"], d["http2"]["akamai_fingerprint_hash"])
impersonate="chrome" selects the newest Chrome preset, which was Chrome 150 in 0.16.3; impersonate="chrome136" pins one. Through two public proxies this call returned the proxy's address with the same JA4 and HTTP/2 hash as the direct run. Both the proxy= and the proxies= form did. A SOCKS5 proxy is the same argument with socks5h://.
Keep the fingerprint and the proxy across requests
A Session holds the target, the proxy and the cookies, so a login flow keeps one identity.
from curl_cffi import requests
s = requests.Session(impersonate="chrome", proxy="socks5h://user:pass@203.0.113.7:1080")
s.get("https://example.com/login")
r = s.get("https://example.com/dashboard")
For concurrency the AsyncSession takes the same two arguments. The README lists "asyncio with proxy rotation on each request" as a feature: pass proxy= per call inside the session.
import asyncio
from curl_cffi import AsyncSession
async def main():
async with AsyncSession(impersonate="chrome") as s:
r = await s.get("https://tls.peet.ws/api/all", proxy="http://user:pass@203.0.113.7:8080")
print(r.json()["ip"])
asyncio.run(main())
Rotating proxies by username
Many pools rotate the exit when the username changes and nothing else does. Issues #294 and #308 on the curl_cffi tracker reported that a session kept the first exit for every request in that setup. The cause sits in libcurl. It reused the connection and the TLS session because the host and port had not changed. The fork added --proxy-credential-no-reuse in a pull request merged on 5 July 2025. curl_cffi enabled the matching proxy_credential_no_reuse option in 0.12, so the session cache is bound to the proxy username. The impersonate guide adds a second reason. A server may tie a TLS session ticket to the address that earned it, so resuming it from another exit looks wrong. Update the package, or open one Session per proxy.
The same engine plugs into a crawler as a download handler; proxies with Scrapy shows that setup.
Does the proxy change the fingerprint?
Not a proxy that does its job. RFC 9110 restricts a CONNECT proxy to blind forwarding, and a SOCKS5 proxy relays bytes. Our table above shows the same JA4 and HTTP/2 hash on every working route. A proxy that terminates TLS is a different animal. It opens its own connection to the target, so the target sees the proxy's handshake. It also presents its own certificate to you.
The public SOCKS5 entries on our list did exactly that on 2 September 2026. All 40 completed the SOCKS handshake. A plain http:// request through three of them returned the entry's own address. Every HTTPS request ended in curl: (60) SSL certificate problem: unable to get local issuer certificate. With -v the certificate for httpbin.org carried the subject CN=httpbin.org, O=Parallels and the issuer CN=Parallels Panel operations. That is a web-panel certificate forged for the hostname we asked for. curl_cffi raised CertificateVerifyError with the same names. Issue #417 on the curl_cffi tracker records the paid version of the same problem. A proxy manager with TLS interception switched on removed the Akamai hash from the request; turning the interception off brought it back.
The certificate error is therefore the useful signal. Do not answer it with -k or verify=False. The request would go through, the exit would read everything inside it, and the target would see the proxy's fingerprint instead of Chrome's. Use another proxy.
What do the proxy errors mean?
Every message below is one we produced on 2 September 2026, with curl 8.21.0 inside curl-impersonate 2.2.2 and curl_cffi 0.16.3. One version note first. A failed CONNECT was exit 56 on curl 8.5.0 and is exit 7 on curl 8.21.0, with the same text.
| message | exit | curl_cffi raises | what happened | fix |
|---|---|---|---|---|
| CONNECT tunnel failed, response 407 | 7 (56 on 8.5.0) | ConnectionError | the proxy wants a password | add user:pass or -U; see fixing 407 |
| CONNECT tunnel failed, response 401 | 7 | ConnectionError | some proxies answer a wrong password with 401 | check the credentials |
| CONNECT tunnel failed, response 400, 403, 502, 503 | 7 (56 on 8.5.0) | ConnectionError | the proxy refuses to tunnel to that host or port | the proxy, not the wrapper; try another entry |
| A libcurl function was given a bad argument | 43 | ValueError, not a curl_cffi class, in 0.16.3 | the proxy answered CONNECT with HTML and no status line | HTTP-only forward proxy; use it for http:// targets only |
| Connection timed out after N milliseconds | 28 | Timeout | dead proxy, or a SOCKS scheme sent to an HTTP port | set --connect-timeout, check the scheme |
| Proxy CONNECT aborted | 56 | ConnectionError | http:// scheme sent to a SOCKS port | use socks5h:// |
| TLS connect error: WRONG_VERSION_NUMBER | 35 | SSLError | https:// scheme on a plain HTTP proxy | write http:// |
| Unsupported proxy scheme | 7 | ConnectionError | a typo in the scheme | http, socks4, socks4a, socks5 or socks5h |
| Failed to connect to host over proxy | 7 | ConnectionError | nothing listens on that port | check host and port |
| User was rejected by the SOCKS5 server (1 1) | 97 | ProxyError | wrong SOCKS5 password | check the credentials |
| No authentication method was acceptable | 97 | ProxyError | the SOCKS5 server requires a password | add user:pass |
| SSL certificate problem: unable to get local issuer certificate | 60 | CertificateVerifyError | the proxy terminates TLS itself | another proxy, never -k |
Two of these rows carry numbers from the probe. Of 40 recent public HTTP entries on our list, 11 carried an HTTPS request end to end. Twenty refused the CONNECT with one of the codes in row three, four answered with the bare HTML of row four, four timed out and one presented a self-signed certificate. Plain curl got the same answers as the wrapper. That comparison is the quickest way to tell a proxy fault from an impersonation fault. The general curl messages, including the DNS ones, are in how to fix curl proxy errors.
How do I verify both signals?
Check the address and the fingerprint in one request, against an endpoint that echoes both. The fp function from the capture prints the three values that matter: the exit address, the JA4 and the HTTP/2 hash.
fp() { python3 -c "import json,sys;d=json.load(sys.stdin);print(d['ip'], d['tls']['ja4'], d['http2']['akamai_fingerprint_hash'])"; }
curl_chrome150 -s -x http://user:pass@203.0.113.7:8080 https://tls.peet.ws/api/all | fp
Three things to compare. The address must be the proxy's. The JA4 must equal the one your real browser shows on the same page. On 2 September 2026 Chrome 151 on Windows gave t13d1516h2_8daaf6152771_806a8c22fdea, the same as the chrome150 target. The JA3 will differ from your browser's and from your last run, because of the extension shuffle described above, so do not use it as the test. The HTTP/2 hash must be the browser's too. The bare binary already had Chrome's HTTP/2 hash with the wrong TLS, which is why both values need a look.
For the address side alone, our proxy checker reports the exit address, the anonymity grade, the real location and the latency in one paste. The free proxy list is the right pool for proving a setup. The numbers above say what to expect from it: 11 of 40 HTTP entries and none of the SOCKS5 entries carried a browser handshake that afternoon. For work that has to keep running, the address side has to be as steady as the handshake side. That is what our residential IPs are for.
What curl-impersonate cannot do
- JavaScript. It patches TLS and HTTP handshakes and runs nothing above them. A challenge page stays unsolved. The curl_cffi FAQ says that for higher protection levels "you may need to find a better proxy IP provider and use browser automation tools like playwright". How to scrape past Cloudflare covers that step.
- A stale target. The fork added chrome150 on 8 August 2026 and announced a chrome152 preset in the 0.16.3 notes. An old preset is a fingerprint of its own. Run
curl-cffi update, or download the next release. - Edge, Brave and other Chromium browsers. The docs note that Chromium browsers share one fingerprint. They differ only in the User-Agent and sec-ch-ua-platform headers, so impersonate Chrome and set the headers yourself.
- A TLS-terminating proxy. See above. The certificate error is the signal.
- Lower layers. A Hacker News reader asked in April 2025 whether the tool also sets the IP time-to-live of the platform it imitates. We did not measure the IP layer. The tool's own claim covers TLS and HTTP only.
- HTTP/3 through a proxy. The binding lists HTTP/3 with a UDP proxy since 0.15.0. We did not test it, and an August 2026 issue reports it failing in the 2.x line.
Where to go from here
Detection happens in layers. Clear the address with a proxy, the handshake with a wrapper or curl_cffi, and the behaviour with pacing and, where a challenge appears, a real browser. This guide is the middle layer, and the check at the end proves it holds through the proxy. From here, how websites detect proxies maps every signal a defender reads. Proxies for web scraping covers choosing the proxy type for a target. The cURL guide is the reference for every proxy flag the wrapper inherits. When a project graduates to production against real defences, get addresses nobody else is burning on our paid pools. They can be ordered and rotated from code, which is what the developer reference is for.
Sources
- curl-impersonate README, lexiforest fork. GitHub, file last changed 8 August 2026. The fork's enhancements, the browser table, the wrapper scripts, the warning about flags that change the signature. github.com/lexiforest/curl-impersonate
- curl-impersonate releases, lexiforest. GitHub. v2.2.2 on 1 September 2026; v2.1.0 (chrome150) on 8 August 2026; v2.0.0 (curl 8.21.0, CMake) on 1 August 2026.
- curl-impersonate documentation: Quick Start, Installation, Impersonation and Fingerprints, API reference. lexiforest, Read the Docs, 2026. The bare binary, the flags that affect the TLS signature, the install paths, the target list, CURLOPT_PROXY_CREDENTIAL_NO_REUSE.
- Pull request #154, Add new option --proxy-credential-no-reuse. lexiforest/curl-impersonate, merged 5 July 2025.
- curl-impersonate README and releases, lwthiker. GitHub. Last release v0.6.1 on 2 March 2024; v0.6.0 on curl 8.1.1.
- Docker Hub tag lists for lexiforest/curl-impersonate and lwthiker/curl-impersonate, read 2 September 2026.
- curl_cffi README and releases, lexiforest. GitHub. v0.16.3 on 2 September 2026. Python 3.10 minimum since v0.14, proxies, Session, AsyncSession, the curl-cffi command. github.com/lexiforest/curl_cffi
- curl_cffi documentation: Advanced Topics (proxies), FAQ, Impersonate guide (PSK and proxy rotation). lexiforest, Read the Docs, 2026.
- curl manual pages for --proxy, --proxy-user, --socks5, --socks5-hostname, --preproxy and --proxytunnel. The curl project, curl 8.21.0.
- RFC 9110, HTTP Semantics, sections 9.3.6 CONNECT and 15.5.8 407 Proxy Authentication Required. IETF, June 2022.
- RFC 8446, The Transport Layer Security (TLS) Protocol Version 1.3, section 4.1.2 Client Hello. IETF, August 2018.
- RFC 1928, SOCKS Protocol Version 5. IETF, March 1996. RFC 9113, HTTP/2, section 6.5 SETTINGS. IETF, June 2022.
- JA3, A method for profiling SSL/TLS Clients. Salesforce, GitHub, 2017. JA4+ Network Fingerprinting. FoxIO, GitHub.
- JA3/JA4 fingerprint, Cloudflare Bots documentation. Cloudflare, last updated 6 May 2026.
- TLS ClientHello extension permutation, feature 5124606246518784. Chrome Platform Status, shipped in Chrome 110.
- Our own run of 2 September 2026: curl-impersonate 2.2.2 (Linux and Windows builds), curl_cffi 0.16.3, plain curl 8.5.0, Python urllib, Chrome 151, tinyproxy 1.11.1 and dante 1.4.3 as local test proxies, and 80 public entries from hproxy.com/free-proxy-list, against tls.peet.ws and tls.browserleaks.com. Raw output is kept in the page's research folder.


