You rotated to a clean residential IP, copied a real browser's headers, spaced out your requests, and the site still blocked you on the first hit. The IP was fine. The headers were fine. The site never read them. It identified your HTTP library from the TLS handshake, a few milliseconds before any HTTP happened, and that handshake said "python-requests" as clearly as a user agent would. This is TLS fingerprinting, it is now standard at every major bot-protection vendor, and no proxy on earth changes it. Here is what it measures and how to fix it in Python.
What the server sees before it sees HTTP
Every HTTPS connection starts with a TLS ClientHello, a message in which your client announces what it supports: the TLS versions it can speak, the cipher suites it accepts and the order it prefers them in, the extensions it includes and their order, the elliptic curves and signature algorithms it offers, ALPN protocols, and more. None of that is secret and none of it is encrypted, because it is the message that sets up the encryption.
The trick is that every TLS implementation makes those choices differently. Chrome on Windows produces one combination; Safari on iOS another; Firefox another; Go's standard library another; Python's ssl module on top of OpenSSL, which is what requests, urllib3 and httpx use, another still. Hash the combination and you have a fingerprint of the software, independent of the IP, the headers, or anything the developer typed.
JA3 and JA4, briefly
JA3, published by Salesforce engineers in 2017, concatenates the version, ciphers, extensions, curves and point formats from the ClientHello and MD5-hashes the string. It works, but it is brittle: browsers began randomising extension order, which changed the hash on every connection, and different applications on the same TLS library collided.
JA4, published by FoxIO in 2023, sorts the ciphers and extensions before hashing, so randomised order no longer matters, and adds readable fields: protocol, SNI presence, cipher count, extension count, ALPN. A JA4 string tells an analyst at a glance "TLS 1.3, 16 ciphers, HTTP/2 offered". Vendors also fingerprint the HTTP/2 layer: the SETTINGS frame values, window size and the order of pseudo-headers differ between Chrome, Firefox and every non-browser client, and Akamai described that technique years ago.
The practical point is that these fingerprints are stable per library and per version, and vendors maintain lists. A Chrome 129 fingerprint arriving with a Chrome 129 user agent is consistent. A python-requests fingerprint arriving with a Chrome user agent is a lie the server can prove in the first packet, and lies are scored harder than honesty.
Why proxies do not help here
A proxy sits between you and the site at the network layer. For HTTPS it opens a tunnel and forwards bytes; the TLS handshake inside the tunnel is negotiated between your client and the target server end to end. The proxy cannot alter it without breaking the encryption. So a residential proxy fixes the question "where is this request coming from" and does nothing about "what software sent it". Both questions are asked. This is the single most common reason people conclude that a residential proxy "does not work" when the proxy did exactly its job.
Seeing your own fingerprint
Several public services echo back the TLS fingerprint they observe. Fetch one with plain requests, then with a browser, and compare. You will see different JA4 strings and, tellingly, requests offering HTTP/1.1 only where the browser offered HTTP/2. That difference alone is enough for a vendor to classify the client.
The fix: curl_cffi
curl_cffi is a Python binding to curl-impersonate, a build of libcurl patched so that its TLS handshake and HTTP/2 behaviour reproduce a real browser's. You use it almost exactly like requests:
pip install curl_cffi
from curl_cffi import requests
r = requests.get("https://example.com/", impersonate="chrome")
print(r.status_code, r.http_version)
impersonate="chrome" selects the latest Chrome profile the library ships; you can pin a version such as chrome124, or use safari, safari_ios, firefox or edge profiles. The library sets the TLS parameters, the HTTP/2 settings and the default header order to match, so the fingerprint and the user agent finally agree.
Sessions and proxies work the way you expect:
from curl_cffi import requests
proxy = "http://USERNAME:PASSWORD@HOST:PORT" # from your dashboard
with requests.Session(impersonate="chrome", proxies={"http": proxy, "https": proxy}) as s:
r = s.get("https://example.com/search?q=widgets", timeout=30)
print(r.status_code)
# cookies persist across s.get() calls like a browser tab
Three notes from using it in production. Keep the impersonation profile and your User-Agent header consistent; if you override the user agent to an older Chrome while impersonating a newer one, you have recreated the mismatch you were trying to remove. Prefer sticky proxy sessions for multi-step flows, because a fingerprint that is stable while the IP changes every request is its own odd pattern. And update the library: profiles track browser releases, and an impersonated Chrome from two years ago is as conspicuous as a real one would be.
Alternatives
- A real browser. Playwright or Selenium driving Chromium produces a genuine Chrome handshake because it is Chrome. Heavier, slower, and headless Chrome has its own tells, but the TLS layer is beyond reproach. See our Playwright scraping guide.
- tls-client and similar libraries in Go, Node and other languages do the same job as curl_cffi for their ecosystems.
- Custom SSL contexts in httpx or urllib3 can adjust cipher lists, but cannot reproduce extension order or HTTP/2 settings, so they narrow the gap without closing it.
Where TLS sits in the detection stack
Fix the handshake and you have removed one signal, not all of them. Vendors score IP reputation, TLS and HTTP/2 fingerprints, header consistency, JavaScript-level browser fingerprints when a page runs scripts, and behaviour over time. The sensible order of work is: use an IP type appropriate to the target, make the TLS fingerprint match the browser you claim, make the headers consistent with both, and only then worry about behaviour. Our guides to browser fingerprint detection and why proxies get blocked cover the other layers. The mistake to avoid is buying more IPs to solve a fingerprint problem; the tenth residential IP with a python-requests handshake fails exactly like the first.
Frequently asked questions
What is a TLS fingerprint?
A hash of the choices your client makes in the TLS ClientHello: protocol version, cipher suites and their order, extensions and their order, supported curves and signature algorithms. Every HTTP library makes those choices differently, so the hash identifies the library before the server has seen a single HTTP header. JA3 is the older hash; JA4 is the newer, more robust one.
Does a proxy hide my TLS fingerprint?
No. A proxy changes the IP address the server sees. The TLS handshake is negotiated end to end between your client and the target, and the proxy passes it through untouched. A residential IP with a python-requests fingerprint is a residential IP that is obviously running a script.
How does curl_cffi work?
It wraps curl-impersonate, a build of libcurl patched to reproduce the TLS and HTTP/2 handshakes of real browsers. You call it like requests, add impersonate equals chrome, and the server sees a Chrome handshake, Chrome HTTP/2 settings and Chrome header order.
Is impersonating a browser fingerprint legal?
Changing how your client negotiates TLS is configuration, not intrusion, and browsers themselves vary these settings between versions. What governs legality is what you do on the site: its terms, the data you collect, and the applicable law. Fingerprint hygiene does not change any of that in either direction.
Do I still need residential proxies if my fingerprint is right?
For protected targets, yes. TLS is one signal; IP reputation is another, and sites score them together. A Chrome fingerprint from a datacenter IP is a headless Chrome in a datacenter, which is also a known pattern. Fix both, then look at behaviour and headers.