What TLS fingerprinting is
When a TLS connection opens, the client sends a Client Hello message that lists its supported cipher suites, TLS extensions, elliptic curves, and other parameters. These lists are specific to the TLS library in use — Chrome uses one combination, Firefox another, Python's ssl module yet another.
Two fingerprinting schemes are widely deployed:
- JA3 — an MD5 hash of five Client Hello fields. Consistent for a given library version; trivially computed by any HTTPS server.
- JA4 — a newer, more granular scheme that also captures extension order and ALPN values.
Cloudflare, Akamai, Datadome, PerimeterX, and several in-house bot protection systems check JA3/JA4. A Chrome User-Agent paired with a Python TLS fingerprint is an instant signal.
Why Python requests gets detected
Python's ssl module (which requests and standard httpx use) produces a JA3 fingerprint that is trivially identifiable as non-browser traffic. It's not a flaw — it's just the OpenSSL defaults on that Python version. No amount of header manipulation fixes this; the fingerprint is negotiated before HTTP even starts.
| Client | JA3 varies? | Looks like browser? |
|---|---|---|
requests | No (fixed per Python version) | No |
httpx (http/2) | Slightly different from requests | No, but less obvious |
curl_cffi (Chrome impersonation) | Yes, per Chrome version | Yes |
| curl (default) | No (fixed per curl/OpenSSL build) | No |
curl with BoringSSL / --tlsv1.3 | Closer to Chrome | Closer |
| undici (Node) | Varies; less fingerprinted than requests | Partial |
Python solutions
Option 1 — curl_cffi (recommended for fingerprint-sensitive targets): wraps libcurl with BoringSSL and can impersonate specific Chrome, Firefox, and Safari versions.
from curl_cffi import requests as cffi_requests
# Impersonate Chrome 124 — full TLS fingerprint match.
r = cffi_requests.get(
"https://target.com/",
impersonate="chrome124",
proxies={
"http": "http://USER:[email protected]:8080",
"https": "http://USER:[email protected]:8080",
},
timeout=20,
)
print(r.status_code)
pip install curl_cffi
chrome110, chrome116, chrome124, firefox120, safari17_0, and others. Match the version you want to mimic. Rotate between versions if the target tracks Client Hello changes over time.Option 2 — httpx with HTTP/2: a different fingerprint from plain requests, less recognisable on targets that haven't built a specific rule for it.
import httpx
with httpx.Client(
http2=True,
proxy="http://USER:[email protected]:8080",
timeout=20,
) as client:
r = client.get("https://target.com/")
print(r.status_code)
Node solutions
undici (and Node's native fetch) uses Node's built-in TLS stack, which produces a fingerprint distinct from Python but still detectable by sophisticated systems. For targets that specifically fingerprint Node, the options are:
undiciwith custom TLS options — adjust cipher suites and ALPN to move the fingerprint closer to a browser.playwright/puppeteer— a real Chromium instance has a real Chrome fingerprint. The heavyweight option, but unbeatable for fingerprint matching.
import { ProxyAgent, fetch } from "undici";
const dispatcher = new ProxyAgent({
uri: "http://USER:[email protected]:8080",
connect: {
// Nudge the cipher list closer to a browser profile.
ciphers: [
"TLS_AES_128_GCM_SHA256",
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-RSA-AES128-GCM-SHA256",
].join(":"),
},
});
const r = await fetch("https://target.com/", { dispatcher });
console.log(r.status);
What the proxy can and can't do
The proxy is a CONNECT tunnel for HTTPS traffic — the TLS handshake happens between your client and the target, invisibly to us. We cannot modify your Client Hello or change your fingerprint. The fingerprint is set entirely by your HTTP client library.