curl_cffi Python: Bypass TLS Fingerprinting & Bot Detection (2026 Guide)

Prerequisites: Comfortable with Python requests, basic understanding of how HTTPS works (TLS handshake, not just HTTP).

The Problem

Indeed, LinkedIn, Cloudflare-protected sites, and hundreds of others run bot detection that fires before your HTTP headers are even read. Python’s requests library uses a TLS stack that produces a JA3 fingerprint — a hash of your cipher list, TLS extensions, and elliptic curves — that has been publicly documented as non-browser traffic for years. Swapping the User-Agent to "Mozilla/5.0 ..." does exactly nothing. The TLS layer already gave you away.

curl_cffi wraps curl-impersonate, a patched build of libcurl that replays the exact TLS handshake each major browser sends — cipher suites, extension order, ALPN negotiation, HTTP/2 settings frames — all of it.

How It Works

Your script
    │
    ├──► requests + urllib3
    │         │
    │         └──► Python TLS stack
    │                   │
    │                   └──► JA3 = known bot hash ──► 403 / CAPTCHA
    │
    └──► curl_cffi (impersonate="chrome120")
              │
              └──► curl-impersonate TLS
                        │
                        └──► JA3 matches real Chrome ──► 200 OK

The server’s bot-detection layer checks the JA3 hash on the TLS handshake. Everything after that — headers, cookies, payload — only matters if you pass this first gate.

The Code

from curl_cffi import requests

# Drop-in replacement for requests.Session
session = requests.Session(impersonate="chrome120")

# Targeting a real site — note: always respect robots.txt and ToS
r = session.get(
    "https://httpbin.org/headers",
    timeout=15,
)

# Inspect what the server received
print(r.json()["headers"])

# Async variant for bulk scraping — same impersonate flag
from curl_cffi.requests import AsyncSession
import asyncio

async def fetch(url):
    async with AsyncSession(impersonate="chrome120") as s:
        resp = await s.get(url, timeout=15)
        return resp.status_code

print(asyncio.run(fetch("https://httpbin.org/get")))
  • impersonate="chrome120" tells curl_cffi to load a pre-built TLS profile that matches Chrome 120’s exact handshake fingerprint — not just the cipher list, but extension order and HTTP/2 SETTINGS frame values.
  • The sync API is a near-complete drop-in for requestssession.get, session.post, session.cookies, and session.headers all work as expected.
  • The async variant uses AsyncSession and standard asyncio — no aiohttp required.
  • httpbin.org/headers is the fastest way to confirm your request looks like a browser: check that Accept-Language, Sec-Fetch-*, and the correct User-Agent are present.
  • Available profiles as of 2024: chrome99, chrome107, chrome110, chrome116, chrome120, firefox117, safari15_5, edge99. Pick the newest Chrome unless you have a reason not to.

Watch Out For This

Wrong — header spoofing only:

import requests

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...",
    "Accept-Language": "en-US,en;q=0.9",
})
r = session.get("https://books.toscrape.com")

This fails on any site using JA3/JA3S fingerprinting. The User-Agent header travels inside the TLS tunnel — the server’s bot-detection system already logged your Python cipher list before it opened the envelope.

Right — impersonate at the transport layer:

from curl_cffi import requests

session = requests.Session(impersonate="chrome120")
r = session.get("https://books.toscrape.com")

One flag replaces the entire TLS profile. The server sees a handshake indistinguishable from Chrome 120 running on Windows.

Bottom Line

Bot detection lives at the TCP handshake level — if your TLS stack doesn’t look like a browser, no amount of header spoofing saves you.

Further reading: JA3 TLS fingerprinting (Salesforce Engineering), curl-impersonate project (GitHub), HTTP/2 fingerprinting (AKAMAI research)

Leave a Reply