TLS Fingerprinting with curl_cffi
How curl_cffi impersonates real browser TLS and HTTP/2 fingerprints in Python, what it handles automatically, and the one header you still need to set yourself.
Note
This content is for security research, testing your own infrastructure, and understanding detection systems. Bypassing bot detection on sites you do not own or operate may violate their terms of service. Always obtain proper authorization before testing against third-party systems.
The Problem: Python’s TLS Signature
Every TLS connection starts with a ClientHello message. The cipher suites, extensions, extension order, supported groups, and signature algorithms in that message form a fingerprint. Different TLS libraries produce different fingerprints.
Python’s requests library uses urllib3 backed by OpenSSL. Its JA3 hash — 8d9f7747675e24454cd9b7ed35c58707 — is one of the most widely recognized automation fingerprints on the internet. Anti-bot systems at Cloudflare, Akamai, and DataDome check JA3/JA4 fingerprints before a single byte of your request body is read. If your TLS handshake says “I’m a Python script,” no amount of header spoofing will help.
The same applies to Go’s net/http, Node’s https, and default curl. Each has a known fingerprint that matches zero real browsers.
How curl_cffi Works
curl_cffi wraps curl-impersonate, which links against BoringSSL — Google’s OpenSSL fork and the same TLS library Chrome actually uses. This isn’t recording your local Chrome. It replays pre-captured browser sessions:
-
Pre-captured TLS profiles: Each browser target has stored ClientHello parameters (cipher suites, extensions, extension order, supported groups, signature algorithms) captured from real browser sessions. These are replayed byte-for-byte.
-
HTTP/2 SETTINGS: Each profile stores the exact SETTINGS frame, WINDOW_UPDATE, and pseudo-header order matching the target browser. Default curl sends pseudo-headers in
mpsaorder, which matches no browser at all. curl_cffi sends Chrome’smasp, Firefox’smpas, or Safari’smspa. -
Default headers: With
default_headers=True(the default), curl_cffi auto-generates correctSec-Ch-Ua,User-Agent,Sec-Fetch-*,Accept, and other headers — all matched to the impersonated version, in the correct order.
The result: ~99.8% JA3 match rates against real Chrome.
from curl_cffi import requests
session = requests.Session(impersonate="chrome136")response = session.get("https://example.com")That single impersonate parameter handles TLS fingerprint, HTTP/2 settings, pseudo-header order, header values, and header ordering.
Supported Browser Targets
As of curl_cffi 0.14 (stable):
| Browser | Targets | Count |
|---|---|---|
| Chrome Desktop | chrome99 through chrome142 | 15 |
| Chrome Android | chrome99_android, chrome131_android | 2 |
| Safari Desktop | safari153 through safari260 | 6 |
| Safari iOS | safari172_ios through safari260_ios | 4 |
| Firefox | firefox133 through firefox144 | 4 |
| Edge | edge99, edge101 | 2 |
| Tor | tor145 | 1 |
Generic aliases (chrome, safari, firefox) always point to the latest target.
Version gaps are intentional. Browser versions are only added when their fingerprints actually change. There’s no chrome102 because Chrome 102’s fingerprint was identical to Chrome 101’s.
A few caveats worth knowing:
- Chrome targets are the most accurate. BoringSSL is Chrome’s TLS library, so the replay is authentic.
- Firefox targets are approximations. Real Firefox uses NSS (Mozilla’s TLS library), not BoringSSL. curl_cffi gets the JA3 hash right but can’t replicate NSS-specific extensions like
delegated_credentials(ext 34) orrecord_size_limit(ext 28). Also, the firefox144 target has a known bug: it reportsrv:135.0in the User-Agent while claiming Firefox 144. A consistency check catches this. - Edge targets are obsolete. Only edge99 and edge101 exist. Since Edge is Chromium-based, use a recent Chrome target instead.
Live Capture Results
Empirical captures against a TLS fingerprint service, using curl_cffi 0.14:
JA3 and JA4 Fingerprints
Chrome 110+ randomizes TLS extension order, so JA3 changes on every connection. JA4 sorts before hashing, producing stable fingerprints.
| Target | JA3 Hash | JA4 |
|---|---|---|
| chrome120 | 9cc9e346... | t13d1516h2_8daaf6152771_02713d6af862 |
| chrome124 | 351d0eae... | t13d1516h2_8daaf6152771_02713d6af862 |
| chrome131 | cdbf6205... | t13d1516h2_8daaf6152771_02713d6af862 |
| chrome133a | a6d135b0... | t13d1516h2_8daaf6152771_d8a2da3f94cd |
| chrome136 | 2d04cd75... | t13d1516h2_8daaf6152771_d8a2da3f94cd |
| chrome142 | 5da544c8... | t13d1516h2_8daaf6152771_d8a2da3f94cd |
JA4 part C changed between chrome131 and chrome133a — Chrome updated its signature algorithms. This means Chrome 133+ has a different JA4 than Chrome 120-131. Both are valid; they represent different real Chrome versions.
The t13d1516h2 prefix decodes as: TLS 1.3, 15 cipher suites, 16 extensions, HTTP/2 ALPN.
HTTP/2 Fingerprints (Akamai Format)
All Chrome targets produce the same HTTP/2 fingerprint:
1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,pBrowsers are completely distinct at this layer:
| Browser | Akamai HTTP/2 Fingerprint | Pseudo-Header Order |
|---|---|---|
| Chrome | 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p | :method :authority :scheme :path |
| Firefox | 1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s | :method :path :authority :scheme |
| Safari | 2:0;3:100;4:2097152;9:1|10420225|0|m,s,a,p | :method :scheme :authority :path |
Chrome’s INITIAL_WINDOW_SIZE is 6,291,456. Firefox’s is 131,072 — 48x smaller. Safari uses entirely different SETTINGS IDs. These differences alone are enough to identify which browser (or non-browser) is connecting.
TLS Cipher Suite Counts
| Browser | Cipher Suites | Extensions |
|---|---|---|
| Chrome | 16 | 18 (15 + 3 GREASE) |
| Firefox | 17 | 16-17 |
| Safari | 20 | 14 |
What curl_cffi Handles Automatically
With a Chrome impersonation target and default_headers=True, curl_cffi sets all of the following correctly:
- User-Agent — matched to the impersonated Chrome version
- Sec-Ch-Ua — correct brand list with version-appropriate GREASE (e.g.,
"Not.A/Brand";v="99"for Chrome 136) - Sec-Ch-Ua-Mobile —
?0for desktop targets - Sec-Ch-Ua-Platform —
"macOS"(all targets default to macOS) - Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-User, Sec-Fetch-Dest — correct values for a navigation request
- Accept — browser-appropriate value including
image/avif,image/webpfor Chrome - Accept-Encoding —
gzip, deflate, br, zstd - Priority — the HTTP priority header Chrome sends
- Header ordering — all headers in Chrome’s characteristic sequence
Do not override these headers. Setting them manually risks getting the values wrong, the order wrong, or both. curl_cffi’s auto-generated values are captured from real browsers. Your manual overrides almost certainly aren’t.
A common mistake is using header-generation libraries that set Sec-Fetch-* values. These libraries frequently produce wrong values (e.g., sec-fetch-user: ?0 instead of ?1 for navigation requests). Let curl_cffi handle it.
What You Must Set Yourself
One header: Accept-Language.
curl_cffi does not set Accept-Language by default. This header should be plausible for the geographic region of your IP address. A request from a German IP with Accept-Language: en-US,en;q=0.9 is suspicious. A request from a German IP with Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 is normal.
from curl_cffi import requests
session = requests.Session(impersonate="chrome136")response = session.get( "https://example.com", headers={"Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"})When you pass headers to a request, curl_cffi merges them with its auto-generated defaults. Your Accept-Language is inserted at the correct position in the header order. The auto-generated headers you didn’t override remain untouched.
That’s the complete list of manual overrides. Everything else — TLS fingerprint, HTTP/2 settings, pseudo-header order, Sec-Ch-Ua, Sec-Fetch-*, Accept, User-Agent — is handled by the impersonation target.
Advanced: Customizing the Fingerprint
Most use cases need nothing beyond impersonate="chrome136". But curl_cffi exposes three levels of customization for cases where you need to tweak the fingerprint while keeping a base profile:
JA3 string override — replace the TLS ClientHello parameters directly:
session = requests.Session( ja3="771,4865-4866-4867-49195-49196,0-11-10-35-16-5,29-23-24,0")Akamai HTTP/2 fingerprint override — replace the HTTP/2 SETTINGS, WINDOW_UPDATE, and pseudo-header order:
session = requests.Session( akamai="1:65536;4:131072;5:16384|12517377|3:0:0:201|m,p,a,s")extra_fp dictionary — fine-grained control over individual TLS and HTTP/2 parameters:
extra_fp = { "tls_signature_algorithms": [...], "tls_grease": False, "tls_permute_extensions": False, "tls_cert_compression": "brotli", "http2_stream_weight": 256,}session = requests.Session(impersonate="chrome136", extra_fp=extra_fp)These levels compose. You can start with a Chrome profile and override specific TLS or HTTP/2 parameters without losing the rest of the profile’s settings.
Known Limitations
-
All targets default to macOS User-Agents. If you need Windows or Linux UAs, you must override both
User-AgentandSec-Ch-Ua-Platformtogether — they must agree. Since most proxy infrastructure runs Linux (TTL=64, matching macOS), sticking with the default macOS identity avoids TCP/IP layer inconsistencies. -
No JA4 customization. curl_cffi lets you override JA3 strings and Akamai HTTP/2 fingerprints directly, but there’s no JA4 override API. The Chrome profiles produce correct JA4 hashes because they replay real handshakes — you just can’t tweak JA4 independently.
-
No Encrypted Client Hello (ECH). RFC 9849 was finalized in March 2026. Neither curl-impersonate nor curl_cffi support ECH yet. This doesn’t affect fingerprinting today, but ECH adoption will eventually change how ClientHello fingerprinting works.
-
Quarterly update lag. Chrome ships new versions every 4 weeks. curl_cffi updates fingerprints roughly quarterly. There’s always a window where the latest Chrome version isn’t available as a target. Using the most recent available target (e.g., chrome142 when Chrome 145 is current) is fine — anti-bot systems expect a distribution of Chrome versions, not just the latest.
-
Firefox accuracy. BoringSSL approximations of NSS behavior have known gaps. If Firefox impersonation is critical, verify against a fingerprint service like tls.peet.ws before relying on it in production.