If you’ve had your HTTP request blocked despite using correct headers, cookies, and clean IPs, there’s a chance you are running into one of the simplest forms of blocking, and one of the most confusing for beginners.
Chances are, you will recognise the problem. You found the hidden API, and your request works perfectly in Postman... but it fails instantly within your Python code.
It’s called TLS fingerprinting. But the good news is, you can solve it. In fact, when I showed this to some developers at Extract Summit, they couldn’t believe how straightforward it was to fix.
CAPTION: “I copied the request -> matching headers, cookies and IP, but it still failed?”
Your TLS fingerprint
Let’s start with a question. How do the servers and websites know you’ve moved from Postman to making the request in Python? What do they see that you can’t? The key is your TLS fingerprint.
To use an analogy: We’ve effectively written a different name on a sticker and stuck it to our t-shirt, hoping to get past the bouncer at a bar.
Your nametag (headers) says "Chrome."
But your t-shirt logo (TLS handshake) very obviously says "Python."
It’s a dead giveaway. This mismatch is spotted immediately. We need to change our t-shirt to match the nametag.
To understand how they spot the “logo”, we need to look at the initial “Client Hello” packet. There are three key pieces of information exchanged here:
- Cipher suites: The encryption methods the client supports.
- TLS extensions: Extra features (like specific elliptic curves).
- Key exchange algorithms: How they agree on a password.
This is because Python’s requests library uses OpenSSL, while Chrome uses Google's BoringSSL. While they share some underlying logic, their signatures are notably different. And that’s the problem.
OpenSSL vs. BoringSSL
The root cause of this mismatch lies in the underlying libraries.
Python’s requests library relies on OpenSSL, the standard cryptographic library found on almost every Linux server. It is robust, predictable, and remarkably consistent.
Chrome, however, uses BoringSSL, Google’s own fork of OpenSSL. BoringSSL is designed specifically for the chaotic nature of the web and it behaves very differently.
The biggest giveaway between the two is a mechanism called GREASE (Generate Random Extensions And Sustain Extensibility).
[
"TLS_GREASE (0xFAFA)",
....
]
Chrome (BoringSSL) intentionally inserts random, garbage values into the TLS handshake - specifically, in the cipher suites and extensions lists. It does this to "grease the joints" of the internet, ensuring that servers don't crash when they encounter unknown future parameters.
This is one of the key changes
-
Chrome: Always includes these random GREASE values (e.g.,
0x0a0a). - Python (OpenSSL): Never includes them. It only sends valid, known ciphers.
So, when an anti-bot system sees a handshake claiming to be "Chrome 120" but lacking these random GREASE values, it knows instantly that it is dealing with a script. It’s not just that your shirt has the wrong logo; it’s that your shirt is too clean.
JA3 hash
Anti-bot companies take all that handshake data and combine it into a single string called a JA3 fingerprint.
Salesforce invented this years ago to detect malware, but it found its way into our industry as a simple, effective way to fingerprint HTTP requests. Security vendors have built databases of these fingerprints.
It is relatively straightforward to identify and block any request coming from Python’s default library because its JA3 hash is static and well-known.
This code snippet would yield the below JSON response.
def get_ja3_info():
url = "https://tls.peet.ws/api/clean"
with requests.Session() as session:
response = session.get(url)
response.raise_for_status()
data = response.json()
print(json.dumps(data))
Note the lack of akamai_hash
{
"ja3":
"771,4866-4867-4865-49196-49200-49195-49199-52393-52392-49188-49192-49187-49191-159-158-107-103-255,0-11-10-16-22-2
3-49-13-43-45-51-21,29-23-30-25-24-256-257-258-259-260,0-1-2",
"ja3_hash": "a48c0d5f95b1ef98f560f324fd275da1",
"ja4": "t13d1812h1_85036bcba153_375ca2c5e164",
"ja4_r":
"t13d1812h1_0067,006b,009e,009f,00ff,1301,1302,1303,c023,c024,c027,c028,c02b,c02c,c02f,c030,cca8,cca9_000a,000b,000
d,0016,0017,002b,002d,0031,0033_0403,0503,0603,0807,0808,0809,080a,080b,0804,0805,0806,0401,0501,0601,0303,0301,030
2,0402,0502,0602",
"akamai": "-",
"akamai_hash": "-",
"peetprint":
"772-771|1.1|29-23-30-25-24-256-257-258-259-260|1027-1283-1539-2055-2056-2057-2058-2059-2052-2053-2054-1025-1281-15
37-771-769-770-1026-1282-1538|1||4866-4867-4865-49196-49200-49195-49199-52393-52392-49188-49192-49187-49191-159-158
-107-103-255|0-10-11-13-16-21-22-23-43-45-49-51",
"peetprint_hash": "76017c4a71b7a055fb2a9a5f70f05112"
}
Putting the above JA3 hash into ja3.zone clearly shows this is a python3 request, using urllib3:
What’s the solution?
As mentioned, simply changing headers and IP addresses won’t make a difference, as these are not part of the TLS handshake. We need to change the ciphers and Extensions to be more like what a browser would send.
The best way to achieve this in Python is to swap requests for a modern, TLS-friendly library like curl_cffi or rnet.
Here is how easy it is to switch to curl_cffi:
from curl_cffi import requests
# note the impersonate argument & import above
def get_ja3_info():
url = "https://tls.peet.ws/api/clean"
with requests.Session() as session:
response = session.get(url, impersonate="chrome")
response.raise_for_status()
data = response.json()
print(json.dumps(data))
"akamai_hash": "52d84b11737d980aef856699f885ca86"
CAPTION: Note - I searched via the akamai_hash here as the fingerprint from the JA3 hash wasn’t in this particular database.
By adding that impersonate parameter, you are effectively putting on the correct t-shirt.
Summary
Make curl_cffi or rnet your default HTTP library in Python. This should be your first port of call before spinning up a full headless browser.
A simple change (which brings benefits like async capabilities) means you don’t fall foul of TLS fingerprinting. curl-cffi even has a requests-like API, meaning it's often a drop-in replacement.



Top comments (0)