TLS fingerprinting is the practice of identifying the software behind an HTTPS connection from the first message it sends, the TLS ClientHello. The list of cipher suites, extensions and their order differs between Chrome, Firefox, curl and Python, and methods such as JA3 and JA4 turn that list into a short hash a server can compare against known clients. A proxy does not change it: with HTTPS through a proxy, the handshake runs end to end between your client and the website.
So if a site is scoring your handshake, a new IP address leaves the score where it was. This guide shows what the fingerprint contains, the difference between JA3 and JA4, the HTTP/2 layer on top, and real hashes we measured from python-requests and curl_cffi through a local authenticating proxy.
Our guide to avoiding blocks while scraping covers pacing, cookies and soft blocks. This one goes one layer down.
What TLS fingerprinting reads: the ClientHello
Every HTTPS connection starts with the client sending a ClientHello in plaintext. It has to be plaintext, because nothing is encrypted yet. It carries:
- The TLS versions the client supports.
- Cipher suites, in the client's order of preference.
- Extensions: server name (SNI), ALPN (which protocols the client speaks, such as
h2orhttp/1.1), supported groups, signature algorithms, key shares, session tickets and more. - Supported groups and point formats, which say which elliptic curves the client can use.
- GREASE values in browsers: random reserved numbers Chrome inserts so servers do not ossify around a fixed list.
None of this is secret, and none of it is chosen by you in ordinary code. It comes from the TLS library (OpenSSL, BoringSSL, NSS, Apple's stack) and how the application configures it. Chrome uses BoringSSL, Firefox uses NSS, and Python's ssl module uses whichever OpenSSL it was built against. Each produces a distinct ClientHello, which is why the ClientHello works as a fingerprint.
JA3 vs JA4
JA3 and JA4 are the two common ways to reduce a ClientHello to something a rule can match.
| JA3 | JA4 | |
|---|---|---|
| Created | 2017, at Salesforce | September 2023, at FoxIO, founded by one of JA3's creators |
| Inputs | TLS version, ciphers, extensions, curves, point formats | Protocol, TLS version, SNI present, cipher and extension counts, ALPN, ciphers, extensions, signature algorithms |
| Order | As sent | Ciphers and extensions sorted before hashing |
| Output | 32-character MD5 hash | Readable prefix plus two truncated SHA-256 hashes |
| Status | Repository archived on 1 May 2025 | Maintained; see the JA4 technical details |
The difference that matters is ordering. Since Chrome 110 in early 2023, Chrome shuffles the order of its TLS extensions on every connection, a change Google made to stop servers depending on one fixed layout (Fastly's write-up measured it in the wild). JA3 hashes the order as sent, so Chrome produces a new JA3 on nearly every connection. JA4 sorts before hashing, so it stays put.
We saw exactly that. Four connections with curl_cffi's Chrome profile gave four different JA3 hashes (fd758f81…, 4e28819c…, 613c68c7…, eadd25ab…) and one JA4 every time: t13d1516h2_8daaf6152771_806a8c22fdea.
Reading a JA4 string
The first block is readable without a lookup table:
| Part | Chrome profile | python-requests | Meaning |
|---|---|---|---|
t | t | t | TLS over TCP (q would be QUIC) |
13 | 13 | 13 | Highest TLS version offered: 1.3 |
d | d | d | SNI present (a domain, not a bare IP) |
| ciphers | 15 | 31 | Number of cipher suites, GREASE ignored |
| extensions | 16 | 12 | Number of extensions, GREASE ignored |
| ALPN | h2 | h1 | First ALPN value: HTTP/2 vs HTTP/1.1 |
The two hashes after the underscores cover the sorted cipher list and the sorted extensions plus signature algorithms. You can tell python-requests from a browser from the prefix alone: 31 ciphers, 12 extensions and HTTP/1.1 is not what any current browser sends.
HTTP/2 fingerprints: SETTINGS, window size and pseudo-header order
Once TLS is up, a client that negotiated h2 sends an HTTP/2 connection preface, and that is a second fingerprint. The common format, printed by both echo services we used, has four fields separated by |:
- SETTINGS: which settings the client sends, in which order, with which values.
- WINDOW_UPDATE: the connection-level flow-control increment.
- PRIORITY frames, if any.
- Pseudo-header order: the order of
:method,:authority,:schemeand:path, abbreviatedm,a,s,p.
Here are the three curl_cffi browser profiles from our run:
chrome 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
firefox 1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s
safari 2:0;3:100;4:2097152;9:1|10420225|0|m,s,a,p
Setting 1 is the header table size, 2 turns server push off, 3 is max concurrent streams, 4 is the initial window size, 5 the max frame size, 6 the max header list size, and 9 declares that the client does not use the old RFC 7540 priority scheme. Each browser picks different values, and each sends its pseudo-headers in a different order. A client that claims to be Firefox in its User-Agent but sends m,a,s,p is contradicting itself.
python-requests has no HTTP/2 fingerprint at all, because it only speaks HTTP/1.1. That is a signal too.
Why python-requests is recognisable
Put the pieces together and a default requests call carries three separate tells:
- The User-Agent says
python-requests/2.34.2. Easy to change. - The ClientHello is OpenSSL's default: 31 ciphers, 12 extensions, stable JA3
0149f47eabf9a20d0893e2a44e5a6323on our machine (Python 3.12.3, OpenSSL 3.0.13). Not changeable through the requests API. - The protocol is HTTP/1.1 with ALPN
http/1.1. Browsers ask forh2.
Changing only the first makes things worse, not better. A Chrome User-Agent over an OpenSSL handshake and HTTP/1.1 is a claim the other two layers disprove. The exact hashes also vary with your OpenSSL build, so another machine may produce a different JA3 for the same requests version, still nothing like a browser's.
Do not overstate what this means in practice. On Cloudflare, for example, JA3 and JA4 fields are only available to Enterprise customers who bought Bot Management. Plenty of sites never look at your handshake. When one does, its blocks tend to show up as 403s or challenge pages that follow you across every IP, which is the pattern our guide to Cloudflare errors when scraping helps you recognise.
What a proxy does not change
This is the part most proxy marketing skips. For an HTTPS target, your client sends the proxy a plain CONNECT host:443 request. The proxy opens a TCP connection to the target and from then on only relays bytes. Your client then performs the TLS handshake with the website through that tunnel. The proxy never sees the ClientHello's contents as anything other than bytes to forward, and it cannot rewrite them without breaking the connection. Our explainer on HTTP vs SOCKS5 proxies walks through both tunnel types; the result is the same for each.
We checked it rather than asserting it. The same requests call, direct and through a local authenticating HTTP proxy:
requests direct ja3=0149f47eabf9a20d0893e2a44e5a6323 ja4=t13d3112h1_e8f1e7e78f70_b26ce05bbdd6
requests proxied ja3=0149f47eabf9a20d0893e2a44e5a6323 ja4=t13d3112h1_e8f1e7e78f70_b26ce05bbdd6
Identical. curl_cffi's Chrome profile gave the same JA4 direct, through the HTTP proxy, and through a SOCKS5 proxy that enforced a username and password.
What the proxy does change:
| Layer | Changed by an ordinary proxy? |
|---|---|
| Source IP, its ASN and its location | Yes: this is what you buy a proxy for |
| DNS resolution of the target | Yes, with socks5h:// or HTTP CONNECT; no with socks5:// |
| TLS ClientHello (JA3, JA4) | No |
| HTTP/2 SETTINGS and header order | No |
| Headers, cookies, User-Agent | No |
| Request rate and timing per IP | Only in that load spreads over more IPs |
The one exception is a service that terminates TLS itself and opens a new connection to the target: some unblocking APIs work that way, and so does a corporate TLS-inspecting proxy. Then the target sees that service's fingerprint, not yours. An ordinary forward proxy is a tunnel, and it leaves the handshake alone.
So choose the IP type for what the target checks about IPs, and fix the fingerprint in the client. Our residential, ISP and datacenter comparison covers the IP half.
Measured: requests vs curl_cffi through a proxy
The table below is what https://tls.peet.ws/api/all reported on 2026-09-29 (UTC), with every request going through a local authenticating HTTP proxy (proxy.py 2.4.10). https://tls.browserleaks.com/json returned the same JA4 and HTTP/2 values for requests and for the Chrome profile. Versions: requests 2.34.2 and curl_cffi 0.16.3, where chrome, firefox and safari resolved to the chrome150, firefox147 and safari2601 profiles.
| Client | HTTP | JA4 | JA3 |
|---|---|---|---|
| python-requests | HTTP/1.1 | t13d3112h1_e8f1e7e78f70_b26ce05bbdd6 | 0149f47e…6323, same every run |
curl_cffi chrome | h2 | t13d1516h2_8daaf6152771_806a8c22fdea | changes per connection |
curl_cffi firefox | h2 | t13d1717h2_5b57614c22b0_3cbfd9057e0d | 6f7889b9…c919 |
curl_cffi safari | h2 | t13d2013h2_a09f3c656075_7f0f34a4126d | ecdf4f49…6671 |
This is the script, trimmed. Run it against your own proxy to see your numbers:
import os
import requests
from curl_cffi import requests as cffi
PROXY = os.environ["PROXY_URL"]
PROXIES = {"http": PROXY, "https": PROXY}
ECHO = "https://tls.peet.ws/api/all"
def show(label, data):
tls, h2 = data["tls"], data.get("http2") or {}
print(f"{label:18} {data['http_version']:9} {tls['ja4']} {tls['ja3_hash']} {h2.get('akamai_fingerprint')}")
show("requests", requests.get(ECHO, proxies=PROXIES, timeout=20).json())
for profile in ("chrome", "firefox", "safari"):
show(f"curl_cffi {profile}", cffi.get(ECHO, impersonate=profile, proxies=PROXIES, timeout=20).json())
Set PROXY_URL to http://USERNAME:PASSWORD@HOST:PORT, with the host, port and credentials copied from your order in the dashboard at https://app.proxyhive.io. The ip field in the echo response confirms the request left through the proxy.
Using curl_cffi with a proxy
curl_cffi wraps curl-impersonate, a build of curl patched to send browser ClientHellos and HTTP/2 prefaces, behind an API close to requests. It supports Python 3.10 and later. Install it with pip install curl_cffi.
One request
import os
from curl_cffi import requests
PROXY = os.environ["PROXY_URL"]
resp = requests.get(
"https://api.ipify.org?format=json",
impersonate="chrome",
proxies={"http": PROXY, "https": PROXY},
timeout=20,
)
print(resp.status_code, resp.json())
impersonate="chrome" always means the newest Chrome profile your installed version ships. Pin a version such as chrome150 if you need results that do not shift when you upgrade.
A session with one proxy
A session keeps cookies and reuses the connection, and takes the proxy once:
import os
from curl_cffi import requests
PROXY = os.environ["PROXY_URL"]
with requests.Session(impersonate="chrome", proxy=PROXY) as s:
ip = s.get("https://api.ipify.org?format=json", timeout=20).json()["ip"]
fp = s.get("https://tls.peet.ws/api/all", timeout=20).json()
print(ip, fp["http_version"], fp["tls"]["ja4"])
In our run this printed the proxy's exit IP, h2, and the Chrome JA4 above.
SOCKS5
Use socks5h://USERNAME:PASSWORD@HOST:PORT so the proxy resolves the target's hostname, not your machine. We ran the session script through a local SOCKS5 server that enforces a username and password: the server received the hostname, not an IP, and the JA4 was the same Chrome value. With a wrong password curl_cffi raised ProxyError with curl error 97, "User was rejected by the SOCKS5 server".
Errors you will see
curl_cffi reports curl's errors, so the codes in our proxy error codes guide apply. A wrong HTTP proxy password gives curl: (7) CONNECT tunnel failed, response 407. For the requests-side setup of the same proxy, see proxies in Python requests.
Keep the fingerprint consistent
Impersonation helps only while every layer tells the same story:
- Do not override the User-Agent with another browser's. curl_cffi sets Chrome's headers for the Chrome profile; replacing the User-Agent with a Firefox string creates the exact mismatch you were avoiding.
- Pick one profile per session and keep it. Rotating profiles inside one cookie session is its own tell.
- Keep the IP stable within a session. A session cookie that hops between IPs looks like theft. Static ISP and datacenter IPs make that free; our ISP proxies are sold from one IP with country, region, city and carrier chosen at checkout.
- Remember JavaScript. TLS is one signal among several. Sites that run challenge scripts check things curl_cffi cannot provide, such as canvas output and browser APIs. When a target needs a real browser, use one.
When fingerprinting is not your problem
Before reaching for impersonation, check the cheaper explanations. If blocks appear only after a burst, it is rate limiting, and a slower pace fixes it. If they appear on one IP but not others, it is IP reputation. If a plain curl call with no special handshake gets the page, the handshake was never the issue.
And if a site has made clear it does not want automated access, a better fingerprint does not change the answer. Our allowed-use policy asks you to respect a target's rate limits and never to degrade its service, whichever client you use.