Markdown source
TLS Fingerprinting with curl_cffi Markdown source
Readable source view for humans. The raw Markdown endpoint remains available for crawlers and agent readers.
---
title: "TLS Fingerprinting with curl_cffi"
description: "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."
kind: note
maturity: budding
confidence: medium
origin: ai-drafted
author: "Agent"
directedBy: "krow"
tags: [python, security, fingerprinting]
published: 2026-03-29
modified: 2026-04-21
wordCount: 1519
readingTime: 7
prerequisites: [bot-detection-2026]
related: [bot-detection-2026]
url: https://krowdev.com/note/tls-fingerprinting-curl-cffi/
---
## Agent Context
- Canonical: https://krowdev.com/note/tls-fingerprinting-curl-cffi/
- Markdown: https://krowdev.com/note/tls-fingerprinting-curl-cffi.md
- Full corpus: https://krowdev.com/llms-full.txt
- Kind: note
- Maturity: budding
- Confidence: medium
- Origin: ai-drafted
- Author: Agent
- Directed by: krow
- Published: 2026-03-29
- Modified: 2026-04-21
- Words: 1519 (7 min read)
- Tags: python, security, fingerprinting
- Prerequisites: bot-detection-2026
- Related: bot-detection-2026
- Content map:
- h2: The Problem: Python's TLS Signature
- h2: How curl_cffi Works
- h2: Supported Browser Targets
- h2: Live Capture Results
- h3: JA3 and JA4 Fingerprints
- h3: HTTP/2 Fingerprints (Akamai Format)
- h3: TLS Cipher Suite Counts
- h2: What curl_cffi Handles Automatically
- h2: What You Must Set Yourself
- h2: Advanced: Customizing the Fingerprint
- h2: Known Limitations
- h2: Sources
- Crawl policy: same canonical content is exposed through HTML, Markdown, and llms-full; no crawler-specific content gate.
:::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](/article/bot-detection-2026/) 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](https://github.com/lexiforest/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.
```python
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) 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.
| 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,p
```
Browsers 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 — one of the [many layers bot detection stacks](/article/bot-detection-2026/) inspect before your request reaches the origin server.
### 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** — `?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-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.
```python
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:
```python
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:
```python
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:
```python
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.
If you want the rest of the request path around this handshake, [DNS Resolution: The Full Picture](/guide/dns-resolution-full-picture/) is the companion walkthrough from resolver to HTTP request.
## Sources
- [curl_cffi documentation — Impersonation](https://curl-cffi.readthedocs.io/en/latest/impersonate/)
- [curl_cffi GitHub repository](https://github.com/lexiforest/curl_cffi)
- [curl-impersonate — A special build of curl that impersonates browsers](https://github.com/lwthiker/curl-impersonate)
- [RFC 9849 — TLS Encrypted Client Hello (ECH)](https://datatracker.ietf.org/doc/html/rfc9849)