JA3 and JA4 TLS fingerprinting, explained
Published: 30 August 2026 · 7 min read
TL;DR: When your client opens an HTTPS connection, the first message it sends - the TLS ClientHello - lists the cipher suites, extensions, and curves it supports, in a specific order. Hashing that list gives a JA3 (older, MD5-based) or JA4 (newer, more robust) fingerprint. Anti-bot systems compare it to known-browser fingerprints and can block a mismatch before the page loads - no CAPTCHA, just a silent 403. Python's requests emits a fingerprint no browser has. To pass, present a real browser's fingerprint (curl_cffi's impersonate, or an actual browser) - and route it through a residential IP so the network layer matches too.
The fingerprint you send before you send anything
Every HTTPS connection starts with a TLS handshake, and the very first message your client sends is the ClientHello. It is not encrypted yet, and it advertises exactly what your client can do: the TLS versions it supports, its list of cipher suites, the extensions it offers, the elliptic curves and signature algorithms - all in a particular order. Different software builds this list differently, and that difference is a fingerprint.
JA3 and JA4 are two ways of turning that ClientHello into a short, comparable string. An anti-bot system keeps a library of fingerprints that real browsers produce. When your handshake arrives, it computes your fingerprint and checks it against that library. If you look like Chrome, you pass this layer. If you look like Python's requests, you can be blocked immediately - before a single byte of the page is served, with no visible challenge. This is why so many scrapers hit a "silent 403" that they cannot explain from the response alone.
How JA3 works
JA3 takes five fields from the ClientHello - the TLS version, the accepted cipher suites, the list of extensions, the elliptic curves, and the curve formats - concatenates their numeric values in order, and hashes the result with MD5. The output is a 32-character string like 769,47-53-5-10-49161... reduced to a hash. Two clients with the same TLS stack produce the same JA3; a browser and a script produce different ones.
JA3 was influential but brittle. Because it hashes a fixed concatenation, small, harmless changes (a reordered extension, a new TLS build) change the hash, and because the inputs are simple to replicate, JA3 is relatively easy to spoof once you know the target value. That combination - easy to break by accident, easy to fake on purpose - is what motivated JA4.
How JA4 improves on it
JA4 is part of the JA4+ suite of fingerprints (JA4 for TLS, plus companions for HTTP, TCP, and more). Compared with JA3 it is:
- Human-readable - the fingerprint encodes what it saw (TLS version, number of cipher suites and extensions, ALPN) rather than one opaque hash, so analysts can reason about it.
- Stable - it sorts fields that browsers legitimately shuffle, so harmless reordering does not change the fingerprint, cutting false mismatches.
- Higher-signal - it captures more of the handshake, so a spoof has to get more things right to pass.
The practical upshot: presenting a browser JA3 is no longer enough on targets that also compute JA4. Modern anti-bot stacks (Cloudflare, Akamai, DataDome and others) increasingly score JA4, or JA3 and JA4 together, alongside the IP and the HTTP/2 fingerprint.
Why libraries get flagged - and how to present a real fingerprint
Python's requests and httpx are built on OpenSSL, which offers a cipher-and-extension list that does not match any shipping browser. No amount of header spoofing changes this, because the fingerprint is set during the handshake, before headers exist. Sending a Chrome User-Agent over an OpenSSL handshake makes it worse: the TLS layer says "Python" while the header says "Chrome", and the disagreement is itself a bot signal.
The clean fix for scripted requests is curl_cffi, which uses a TLS stack that reproduces a real browser's ClientHello. You pick which browser to impersonate and it emits that browser's JA3/JA4:
from curl_cffi import requests
# emits a real Chrome ClientHello - browser-accurate JA3/JA4
proxies = {"https": "http://USER:PASS@gw.roamproxy.com:41080"}
r = requests.get("https://target.example", impersonate="chrome124", proxies=proxies)
print(r.status_code)
If the target also runs a JavaScript challenge, curl_cffi is not enough on its own - you need a real browser (Playwright/headless Chromium) that both carries a genuine fingerprint and runs the JS. See using residential proxies with browser-use.
The fingerprint and the IP have to agree
A browser-accurate TLS fingerprint solves one layer. It does not change the IP you exit from, and a perfect Chrome fingerprint arriving from a datacenter ASN is a contradiction anti-bot systems are built to catch. The two checks are independent: fix the fingerprint with curl_cffi or a real browser, and fix the network with a residential proxy so the IP looks like a real home connection. For where TLS fits among the other checks, see how to get past Cloudflare and how to get past Akamai Bot Manager.
Use this for legitimate data collection. Understanding TLS fingerprints is for scraping publicly accessible data at a reasonable rate and for building compatible clients. Honor robots.txt and a site's terms where they apply, do not access data behind an authorization you do not have, and rate-limit so you never degrade a site for its real users.
FAQ
What is the difference between JA3 and JA4?
Both are fingerprints of the TLS ClientHello, but JA4 is the newer, more robust design. JA3 concatenates a fixed set of ClientHello fields and hashes them with MD5, which made it easy to spoof and easy to break by trivial reordering. JA4 (part of the JA4+ family) is human-readable, sorts certain fields so it is stable across harmless variation, and captures more signal - so it is harder to fake and harder to accidentally match. Modern anti-bot stacks increasingly key on JA4 or on JA3 and JA4 together.
Why does my request work in the browser but get blocked as a script?
Because your browser and your script send different ClientHellos. Chrome offers a specific set of cipher suites and extensions in a specific order that produces a well-known fingerprint; Python's requests (built on OpenSSL) produces a different one that no real browser emits. The anti-bot system reads the fingerprint during the handshake and blocks the one it does not recognize - silently, before your code sees a single byte of the page.
Can I just change my User-Agent to fix this?
No. The User-Agent is an HTTP header sent inside the encrypted connection, after the TLS handshake is already done. The JA3/JA4 fingerprint is computed from the handshake itself, before any headers. Sending a Chrome User-Agent while emitting a Python TLS fingerprint is actually a stronger bot signal - the two disagree. You have to change the TLS layer, not the header.
Does a residential proxy change my TLS fingerprint?
No - and that is the point of understanding both layers. A proxy changes the IP your traffic exits from; it does not touch your ClientHello, so your JA3/JA4 fingerprint is unchanged. IP reputation and TLS fingerprint are two independent checks. You fix the IP with a residential proxy and the fingerprint with curl_cffi or a real browser; hard targets check both, so you often need both.
A browser-accurate TLS fingerprint from a flagged datacenter IP still loses - anti-bot systems score the IP and the fingerprint together, and they have to agree. Roam residential IPs - rotating at $2/GB, static at $4/IP per month, over HTTP and SOCKS5 - give the network layer a real home-broadband ASN to match your browser fingerprint. Create an account and get 300MB of free trial traffic to test.