Agent context packet

Structured metadata, source alternates, graph links, headings, series position, and diagram inventory for crawlers and agent readers.

TLS Fingerprinting with curl_cffi

How curl_cffi impersonates browser TLS and HTTP/2 fingerprints in Python — what it handles automatically and the one header you still need to set.

/ directed by / / 8 min read
On this page

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 a widely flagged automation fingerprint: published in threat-intel feeds and matched by default in major anti-bot rule sets. 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: the ClientHello matches the target browser’s profile — the same cipher suites, extensions, and ordering a real Chrome emits (GREASE values randomize per connection, exactly as Chrome’s do).

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 safari26017
Safari iOSsafari172_ios through safari260_ios4
Firefoxfirefox133, firefox135, firefox1443
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,p,a:method :scheme :path :authority

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 — one of the many layers bot detection stacks inspect before your request reaches the origin server.

TLS cipher-suite counts

The per-browser cipher and extension counts that the JA4 prefix encodes (Chrome 16/18, Firefox 17/16-17, Safari 20/14) live in the bot-detection overview alongside the rest of the Layer 1 fingerprint surface. They’re the why behind the prefix t13d1516h2; what matters for curl_cffi specifically is that the impersonated profile replays them correctly.

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). ECH is still on the IETF TLS WG track (draft-ietf-tls-esni) — not yet an RFC. Neither curl-impersonate nor curl_cffi support it. This doesn’t affect fingerprinting today, but if/when ECH ships in Chrome stable and gets enabled by default, the visible ClientHello collapses to a generic outer envelope and JA3/JA4 over the public handshake stops discriminating.

  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.

If you want the rest of the request path around this handshake, DNS Resolution: The Full Picture is the companion walkthrough from resolver to HTTP request.

Sources

Diagram

Drag to pan · scroll or pinch to zoom · Esc to close