How to Fix TLS Fingerprinting Blocks in Python Requests

If your headers are perfect and the site still blocks you, the block happened before your first header arrived. TLS fingerprinting identifies your HTTP client from the shape of its TLS handshake, and the Python requests library has a handshake that no browser on earth produces. You cannot fix that by copying a user agent string. You fix it by making the handshake itself look like a browser. This post shows you how to read your own fingerprint, how to change it, and how to tell this problem apart from the two it gets confused with.
What you need
- Python 3.10 or newer. The examples use the requests library first, to show the problem, then curl_cffi to fix it.
- A terminal and about ten minutes. The diagnostic step takes two requests and needs no code changes.
- CapSkip running for the last section only, either in Local mode on the loopback address or in Server mode on a machine your workers can reach. Both modes are described under connection settings, so choose one before you start.
What TLS fingerprinting actually reads
Every TLS connection opens with a ClientHello message. That message lists the TLS versions you support, the cipher suites you offer, the extensions you send, the elliptic curves you accept, and the order you put all of them in. None of that is secret and none of it is configurable per request. It is decided by whichever TLS library your HTTP client was built against.
Hash that list and you get a stable identifier for the client software. JA3 was the first widely used version of that hash. JA4 is the current one, and it sorts the ClientHello extensions before hashing so that the same browser does not produce a different fingerprint on every connection. Cloudflare describes both as a way to identify TLS clients by how they initiate connections, and exposes the value to firewall rules, to analytics and to Workers.
Here is why that outranks any header you set. OpenSSL as Python ships it, BoringSSL as Chrome ships it, and NSS as Firefox ships it all send different ClientHello messages. So a request claiming to be Chrome while handshaking like Python is not a subtle tell that needs clever detection. It is two fields that disagree, and a bot rule can compare them in one line.
Step 1: read your own fingerprint before you change anything
Do not guess at whether TLS fingerprinting is your problem, because you can measure it. There is a public endpoint that echoes back the JA3 hash it saw on your connection, and comparing two requests against it takes under a minute.
# pip install requests
import requests
# The endpoint echoes back the handshake it received from you.
r = requests.get("https://tls.browserleaks.com/json", timeout=30)
seen = r.json()
print(seen["ja3_hash"]) # stable per TLS library, not per user agent
print(seen["ja3_text"]) # the raw cipher and extension listNow run the same call again with a browser user agent set in the headers, and read the hash a second time. It will not have moved. That is the whole lesson in one experiment: the user agent lives in a header, the fingerprint lives in the handshake, and changing the first does nothing to the second. If you have been rotating user agents to get past a block, this is why it has not worked.
Step 2: handshake like a real browser with curl_cffi
You cannot make the standard Python TLS stack produce a Chrome ClientHello, because the extension set and its ordering are baked into the library build rather than exposed as settings. What you can do is use a different library. The curl_cffi package binds to a build of curl that reproduces specific browser handshakes, and it exposes a requests-shaped API so the rest of your code barely changes.
# One dependency, no browser and no driver involved. pip install curl_cffi --upgrade
The argument that does the work is impersonate. It selects which browser build the handshake should copy, and you can pin a specific version when a site has grown picky about the current one.
# pip install curl_cffi
import curl_cffi
# Same call as before, but the handshake now matches Chrome.
r = curl_cffi.get("https://tls.browserleaks.com/json", impersonate="chrome")
print(r.json()["ja3_hash"]) # a different hash from the requests run above
# Pin a version when a site rejects the current default.
r = curl_cffi.get("https://example.com/", impersonate="chrome124")
print(r.status_code)Read the two hashes side by side. If they differ, the impersonation is live, and that is the only confirmation worth having. The project documents the supported targets, which include Safari and the iOS Safari build alongside Chrome. Hand-written JA3 strings are accepted too, though a maintained preset beats one in almost every case, because presets get updated when browsers do.
Move to a session once it works. You want connection reuse and a cookie jar, for the same reasons you wanted them with requests.
# pip install curl_cffi
import curl_cffi
# One session, one handshake profile, cookies carried across calls.
s = curl_cffi.Session(impersonate="chrome")
s.get("https://example.com/login")
r = s.get("https://example.com/dashboard")
print(r.status_code, len(s.cookies))Step 3: keep the rest of the client consistent
A browser handshake attached to an obviously scripted request is its own kind of mismatch, and fixing one signal while leaving the others loud is the most common way this goes wrong. Three things have to agree with the profile you picked.
Your user agent has to name the same browser family and roughly the same version as the impersonation target. Claiming Firefox while handshaking as Chrome is worse than claiming nothing at all, because it turns a weak signal into a confident one.
Your header order matters, and so does your HTTP/2 behaviour. Browsers send a stable header order, a stable set of HTTP/2 settings frames and a stable pseudo header ordering, all of which fingerprint just as cleanly as the TLS layer does. Sending headers in whatever order your dictionary happened to iterate undoes the work you just did. That is one more argument for a maintained impersonation preset, which handles the HTTP/2 layer for you instead of leaving it to your client defaults.
Your IP has to make sense for the traffic you are sending. A datacentre address running browser-shaped requests at machine speed is a separate signal, and no handshake repairs it. There is a full write-up of the options in the post on CAPTCHA proxy rotation, which covers where rotation belongs inside a worker pool.
Ranking the signals, so you fix them in the right order
| Signal | Where it is read | Can you change it |
|---|---|---|
| TLS ClientHello, hashed as JA3 or JA4 | Before any HTTP data is sent | Yes, by swapping the TLS library |
| HTTP/2 settings and pseudo header order | First frames of the connection | Yes, and a good preset does it for you |
| Header names, values and order | The request itself | Yes, and it is the easiest one to get wrong |
| IP reputation and address type | The connection source | Only by changing where you connect from |
| Browser runtime signals such as canvas and WebGL | Inside a real page, after JavaScript runs | Not applicable when you are not running a browser |
| Request rate and navigation pattern | Across many requests | Yes, by slowing down and varying the paths you hit |
Work down that table rather than across it. TLS fingerprinting happens earliest in the connection, so it is the cheapest place for a defender to make a decision, and it is the first place your fix has to land.
What none of this fixes
A correct handshake is not an entry ticket. It removes one reason to distrust you, which means the site now evaluates the rest of your traffic instead of dropping you at the door. Plenty of sites will still show you a challenge after you fix the fingerprint, and that is the expected outcome rather than a sign the work failed.
That is the honest boundary, and it is worth stating plainly. CapSkip does not change your TLS fingerprint and it is not a stealth layer. It solves the CAPTCHA you were served, which is the part of this problem that has a token at the end of it. If a Turnstile widget or a challenge page is what stands between you and the response body, the solver produces the token and your existing client submits it.
# pip install capskip
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
# The challenge you were served, solved on your own machine.
result = solver.turnstile(
sitekey="YOUR_SITEKEY",
url="https://example.com/protected",
)
token = result["code"]
agent = result["userAgent"] # send this exact user agent with the tokenThat returned user agent is not decoration. Turnstile ties the token to the browser identity that produced it, so submitting a perfectly valid token under a different user agent is a reliable way to have it rejected. Send the pair together and the submission holds. The Cloudflare Turnstile solver page covers the widget case and the challenge page case separately, because they need different inputs.
Running the solver on a server instead
Fingerprint work usually shows up in a worker pool rather than in one script, and a pool does not share a loopback address. The connection settings cover both shapes:
| Mode | Listens on | Use it when |
|---|---|---|
| Local | 127.0.0.1, that device only | Your scraper and the solver run on one machine |
| Server | Your network address or public IP | Workers, a VPS or a hosted platform call in over the API |
Point the SDK host at the solver machine and nothing else in your code changes, so a whole fleet can share one instance. A static public IP is recommended when the callers sit outside your own network. The details live under connection settings, and Server mode is still your hardware and still unmetered: it moves where the solver runs, never who owns it.
FAQ
Can I change the TLS fingerprint of the requests library itself?
Not usefully. You can reorder the cipher suites through the underlying SSL context and move the hash, but you cannot reproduce a browser ClientHello that way, because the extension set and its ordering come from the library build rather than from configuration. What you end up with is a fingerprint that matches nothing at all, which is a worse position than matching Python. Swap the library instead.
Does driving a real browser make this go away?
Yes for the TLS layer, since a real Chrome sends a real Chrome handshake. It also costs you hundreds of megabytes per worker and a much slower request, so it is a heavy answer to a narrow problem. Reach for a browser when you genuinely need the page to execute JavaScript, and reach for an impersonating HTTP client when you only need the response body.
How do I know the block was fingerprinting and not rate limiting?
Slow right down and try again from the same client. A rate limit relaxes when you wait, usually tells you how long to wait, and returns cleanly afterwards. A TLS fingerprinting block does not care about timing at all, so the first request of the day fails exactly like the hundredth. If a cold client fails on its very first request, waiting is not your fix.
Does CapSkip change my JA3 or JA4 fingerprint?
No, and nothing about it should. CapSkip is a solver: you hand it a challenge and it hands back a token, on hardware you own and with no per-solve charge. Your fingerprint belongs to the client that makes the request, so that part stays your job. The two pieces fit together well in practice, because a browser-shaped client gets served a solvable challenge instead of a flat refusal.
The shortest version
Read your fingerprint first, because it takes one request and settles the argument. If the hash never moves while you rotate user agents, the handshake is the problem and no header will fix it. Swap to an impersonating client, keep the user agent, the header order and the HTTP/2 profile consistent with whatever you impersonated, then look at your IP. A challenge appearing afterwards is progress rather than failure, and a local captcha solver is the piece that turns it into a token. Notes on wiring one into a worker pool live under CAPTCHA solver for web scraping. When you get to the code itself, the Python captcha solver page carries the SDK details you will want next.
