Kindnote
Maturitybudding
Confidencemedium
Originai-drafted
Created
Tagspython, security, fingerprinting
Prerequisites
Related
Markdown/note/tls-fingerprinting-curl-cffi.md
See what AI agents see
🤖 This content is AI-generated. What does this mean?
note 🪴 budding 🤖 ai-drafted

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:

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

  2. 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 mpsa order, which matches no browser at all. curl_cffi sends Chrome’s masp, Firefox’s mpas, or Safari’s mspa.

  3. Default headers: With default_headers=True (the default), curl_cffi auto-generates correct Sec-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):

BrowserTargetsCount
Chrome Desktopchrome99 through chrome14215
Chrome Androidchrome99_android, chrome131_android2
Safari Desktopsafari153 through safari2606
Safari iOSsafari172_ios through safari260_ios4
Firefoxfirefox133 through firefox1444
Edgeedge99, edge1012
Tortor1451

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) or record_size_limit (ext 28). Also, the firefox144 target has a known bug: it reports rv:135.0 in 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.

TargetJA3 HashJA4
chrome1209cc9e346...t13d1516h2_8daaf6152771_02713d6af862
chrome124351d0eae...t13d1516h2_8daaf6152771_02713d6af862
chrome131cdbf6205...t13d1516h2_8daaf6152771_02713d6af862
chrome133aa6d135b0...t13d1516h2_8daaf6152771_d8a2da3f94cd
chrome1362d04cd75...t13d1516h2_8daaf6152771_d8a2da3f94cd
chrome1425da544c8...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,p

Browsers are completely distinct at this layer:

BrowserAkamai HTTP/2 FingerprintPseudo-Header Order
Chrome1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p:method :authority :scheme :path
Firefox1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s:method :path :authority :scheme
Safari2: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

BrowserCipher SuitesExtensions
Chrome1618 (15 + 3 GREASE)
Firefox1716-17
Safari2014

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?0 for 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/webp for Chrome
  • Accept-Encodinggzip, 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

  1. All targets default to macOS User-Agents. If you need Windows or Linux UAs, you must override both User-Agent and Sec-Ch-Ua-Platform together — they must agree. Since most proxy infrastructure runs Linux (TTL=64, matching macOS), sticking with the default macOS identity avoids TCP/IP layer inconsistencies.

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

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

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

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