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 h2 or http/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.

JA3JA4
Created2017, at SalesforceSeptember 2023, at FoxIO, founded by one of JA3's creators
InputsTLS version, ciphers, extensions, curves, point formatsProtocol, TLS version, SNI present, cipher and extension counts, ALPN, ciphers, extensions, signature algorithms
OrderAs sentCiphers and extensions sorted before hashing
Output32-character MD5 hashReadable prefix plus two truncated SHA-256 hashes
StatusRepository archived on 1 May 2025Maintained; 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:

PartChrome profilepython-requestsMeaning
tttTLS over TCP (q would be QUIC)
131313Highest TLS version offered: 1.3
dddSNI present (a domain, not a bare IP)
ciphers1531Number of cipher suites, GREASE ignored
extensions1612Number of extensions, GREASE ignored
ALPNh2h1First 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 |:

  1. SETTINGS: which settings the client sends, in which order, with which values.
  2. WINDOW_UPDATE: the connection-level flow-control increment.
  3. PRIORITY frames, if any.
  4. Pseudo-header order: the order of :method, :authority, :scheme and :path, abbreviated m, 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:

  1. The User-Agent says python-requests/2.34.2. Easy to change.
  2. The ClientHello is OpenSSL's default: 31 ciphers, 12 extensions, stable JA3 0149f47eabf9a20d0893e2a44e5a6323 on our machine (Python 3.12.3, OpenSSL 3.0.13). Not changeable through the requests API.
  3. The protocol is HTTP/1.1 with ALPN http/1.1. Browsers ask for h2.

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:

LayerChanged by an ordinary proxy?
Source IP, its ASN and its locationYes: this is what you buy a proxy for
DNS resolution of the targetYes, with socks5h:// or HTTP CONNECT; no with socks5://
TLS ClientHello (JA3, JA4)No
HTTP/2 SETTINGS and header orderNo
Headers, cookies, User-AgentNo
Request rate and timing per IPOnly 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.

ClientHTTPJA4JA3
python-requestsHTTP/1.1t13d3112h1_e8f1e7e78f70_b26ce05bbdd60149f47e…6323, same every run
curl_cffi chromeh2t13d1516h2_8daaf6152771_806a8c22fdeachanges per connection
curl_cffi firefoxh2t13d1717h2_5b57614c22b0_3cbfd9057e0d6f7889b9…c919
curl_cffi safarih2t13d2013h2_a09f3c656075_7f0f34a4126decdf4f49…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:

  1. 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.
  2. Pick one profile per session and keep it. Rotating profiles inside one cookie session is its own tell.
  3. 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.
  4. 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.