You copied the exact headers from your browser's network tab. Same User-Agent, same cookies, same everything. The browser gets a 200; your Python script gets a 403. What's different? The handshake that happens before a single header is sent.
When a client opens an HTTPS connection, its TLS ClientHello advertises supported cipher suites, extensions, and curves — in a specific order. That combination is fingerprintable (the JA3/JA4 families of hashes), and it is remarkably stable per client: every Chrome installation of a given version looks the same, and Python's requests looks like exactly what it is — OpenSSL driven by a script. Anti-bot vendors classify the connection before your carefully-copied headers ever arrive.
curl_cffi binds Python to curl-impersonate, a patched libcurl that reproduces real browsers' TLS and HTTP/2 fingerprints. The API is deliberately requests-like:
from curl_cffi import requests
resp = requests.get(
"https://protected-site.com/data",
impersonate="chrome",
timeout=30,
)
print(resp.status_code) # 200 where plain requests got 403
One argument — impersonate="chrome" — swaps the entire handshake identity. Sessions, proxies, and POSTs work the way you expect from requests.
| Situation | Right tool |
|---|---|
| Open API or unprotected site | httpx / requests |
| TLS-fingerprinting anti-bot (403s on clean requests) | curl_cffi |
| Content rendered client-side by JavaScript | Playwright / Selenium |
| JS rendering and fingerprinting | Browser automation + stealth measures |
The efficiency difference matters at scale: a curl_cffi request costs milliseconds and megabytes; a headless browser costs seconds and hundreds of megabytes. If the data is server-rendered or API-backed, impersonated HTTP is the production choice — it's the backbone of most of the 300+ Actors I run on Apify.
Session so cookies and connection state persist like a real visitor's.A technique where servers identify clients by the characteristics of their TLS handshake (cipher suites, extensions, and their order — hashed as JA3/JA4). It distinguishes real browsers from scripting libraries before any HTTP content is exchanged.
Because the block happens at the TLS layer, not the header layer. Python's default SSL stack has a recognizably non-browser handshake; copying headers doesn't change it. curl_cffi replaces the handshake itself.
Dramatically — it makes plain HTTP requests (milliseconds, low memory) while Playwright runs a full browser (seconds, hundreds of MB). Use browser automation only when JavaScript rendering is genuinely required.
I build scrapers, Actors, and data pipelines as a service — fixed quote, fast turnaround.
Start a project →