What Is TLS Fingerprinting (JA3/JA4) and How to Avoid It
TLS fingerprinting (JA3/JA4) flags scraper traffic before a page loads. See how the ClientHello gives you away and how to match a real browser to avoid it.

TLS fingerprinting identifies the software behind an HTTPS request from the shape of its TLS handshake, before a single byte of your HTTP request or a single header is read. That is why a scraper can send a flawless Chrome User-Agent and still get blocked on the first request. The block came from the network packet, not the header. This guide explains exactly what gets fingerprinted, how JA3 and JA4 hashes are built from it, why default HTTP clients like requests and Go's net/http stand out, and the three practical ways to make your traffic present a browser-realistic TLS profile.
TLS Fingerprinting in One Minute
Every HTTPS connection opens with a TLS handshake. The client speaks first, sending a ClientHello message that lists everything it supports: TLS versions, cipher suites, extensions, elliptic curves, and signature algorithms. Different software builds this list differently. Chrome offers one exact set in one exact order, Firefox offers another, and Python's requests offers a third that matches no mainstream browser at all.
TLS fingerprinting turns that ClientHello into a short, comparable identifier. Two well-known schemes do this: JA3 (a 2017 method from Salesforce) and JA4 (its 2023 successor from FoxIO). An anti-bot service computes the fingerprint from the raw handshake packet, then checks it against known-good browser fingerprints and known-bad automation fingerprints. No JavaScript, no cookies, no header parsing. Just the first packet.
The important part for scrapers: this signal is invisible to your application code. You cannot set it with a header, because it is decided by the TLS library your client links against, not by anything you pass to requests.get().
Inside the TLS ClientHello
To understand the fingerprint, look at what the ClientHello actually carries. These are the fields both JA3 and JA4 read from:
| ClientHello field | What it contains | Why it varies by client |
|---|---|---|
| TLS version | Highest protocol the client offers (1.2, 1.3) | Older libraries cap at 1.2 |
| Cipher suites | Ordered list of encryption algorithms | Browsers ship a specific set in a specific order |
| Extensions | Features like SNI, ALPN, supported_versions, session tickets | Browsers send many; minimal clients send few |
| Elliptic curves | Named groups for key exchange (`X25519`, `secp256r1`) | Order and membership differ per client |
| Signature algorithms | Which signature schemes the client accepts | Chrome and Firefox lists differ |
| ALPN | Application protocols offered (`h2`, `http/1.1`) | Modern browsers offer `h2`; many scrapers do not |
The insight that trips people up: it is not any single value that gives you away, it is the exact combination and order. A real Chrome build always sends the same cipher list in the same sequence with the same extensions. A generic HTTP client sends a different combination, and that difference is enough to classify it.
If you want the background on where TLS sits relative to HTTP and SOCKS, our guide to proxy protocols covers the layers underneath this handshake.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
How a JA3 Fingerprint Is Calculated
JA3 concatenates five ClientHello fields into a single string, in this fixed order:
SSLVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
Each field is a list of decimal values joined by dashes, and the five fields are joined by commas. A real string looks like this:
769,49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-21,29-23-24,0
That string is then hashed with MD5 to give the 32-character JA3:
cd08e31494f9531f560d64c695473da9
The hash is just a convenient label. The signal lives in the pre-hash string. If your cipher list or extension list differs from a browser's by one value or one position, you get a completely different MD5, and any detector with a browser allowlist sees an unknown fingerprint.
GREASE values (the reserved random values browsers inject per RFC 8701) are stripped before hashing, so they do not add noise. Everything else counts.
How JA4 Fixes JA3's Weak Spots
JA3 has a real problem on modern browsers. Starting with Chrome 110 in early 2023, Chrome randomizes the order of its TLS extensions on every connection. Because JA3 is order-sensitive, this means a single Chrome install produces a different JA3 on every request. JA3 became unstable for the world's most common browser, which is exactly the traffic scrapers most want to blend into.
JA4 was built to survive that. It is part of the JA4+ family (JA4 for the TLS client, JA4S for the server, JA4H for HTTP headers, JA4X for certificates). The TLS client fingerprint uses a readable a_b_c structure instead of one opaque hash:
t13d1516h2_8daaf6152771_02713d6af862
Breaking that apart:
| Part | Example | Meaning |
|---|---|---|
| `a` | `t13d1516h2` | `t`=TLS over TCP, `13`=TLS 1.3, `d`=SNI present (a domain, not a bare IP), `15`=15 cipher suites, `16`=16 extensions, `h2`=ALPN is HTTP/2 |
| `b` | `8daaf6152771` | Truncated SHA256 of the cipher suites, sorted |
| `c` | `02713d6af862` | Truncated SHA256 of the extensions plus signature algorithms, sorted |
The key change is in parts b and c: JA4 sorts the cipher and extension lists before hashing them. Chrome's per-connection extension shuffling no longer changes the fingerprint, because sorting cancels the shuffle out. JA4 also filters GREASE, SNI, and ALPN out of the extension hash so those do not skew it, and it adds QUIC support (q in part a) for HTTP/3.
There is a subtle detection angle here that most scraping guides miss. The d versus i marker in part a records whether you sent an SNI (a hostname) or connected to a bare IP with none. A scraper that skips SNI, or a proxy setup that connects by IP, produces an i fingerprint that no real browser ever generates, because browsers always send SNI. That single character can flag you even if your cipher list is perfect.
Why requests, axios, and Go Get Flagged
Default HTTP clients get flagged because their TLS stack is not a browser's TLS stack, and it was never trying to be.
- Python
requestsusesurllib3, which uses the system OpenSSL. ItsClientHelloreflects OpenSSL's defaults: a cipher list and extension set that no shipping browser sends. Setting a ChromeUser-Agentdoes nothing to the handshake. - Node.js and axios use Node's TLS module, which again links OpenSSL with Node's own preferences. Recognizable as Node, not Chrome.
- Go
net/httphas a very distinctive Go TLSClientHello. Anti-bot vendors keep a fingerprint for it specifically because Go is popular for scrapers. curlships its own well-known fingerprint that maps straight to "command-line tool."
None of these are broken. They are just honest about what they are. The mismatch that gets you blocked is sending User-Agent: Chrome/124 over a TLS handshake that says Python-urllib3. Detectors love that contradiction, because a genuine Chrome user cannot produce it. Fixing headers alone never closes the gap, which is why header rotation guides only take you so far. TLS is a separate layer from the broader set of block signals like IP reputation, header order, and browser behavior.
How Anti-Bot Systems Use TLS Fingerprints
Cloudflare, DataDome, Akamai, and PerimeterX all read the TLS fingerprint at the edge, during the handshake, before your request reaches an origin server. They use it in three ways:
- Allowlisting. Traffic whose JA3 or JA4 matches a current browser passes the first gate cheaply. Traffic with an unknown or known-automation fingerprint gets extra scrutiny, a challenge, or an outright block.
- Consistency checks. They cross-reference the TLS fingerprint against the HTTP layer. A Chrome JA4 paired with a
User-Agentclaiming Firefox, or with an HTTP/2 SETTINGS frame that Chrome never sends, is a contradiction that scores against you. - Rate and reputation grouping. Requests sharing one automation fingerprint get grouped and rate-limited together, even across rotating IPs. Rotating proxies does not help if every request carries the same tell-tale
Go-http-clientJA3.
The practical takeaway is that TLS fingerprinting is a filter you clear once per request shape, not per IP. Get the fingerprint right and you clear a gate that new proxies alone can never open.
How to Avoid TLS Fingerprinting
There are three approaches that actually work. They trade off speed against realism.
Option 1: Impersonate a browser with curl_cffi
curl_cffi is a Python binding to curl-impersonate, a patched build of curl that reproduces the exact TLS and HTTP/2 fingerprints of real browsers. It is the fastest fix because it stays at the HTTP-client level with no browser to drive.
pip install curl_cffi
from curl_cffi import requests
# Reproduces a real Chrome TLS + HTTP/2 fingerprint, not urllib3's
resp = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome",
proxies={
"http": "http://USER:PASS@gate.sparkproxy.io:10000",
"https": "http://USER:PASS@gate.sparkproxy.io:10000",
},
)
data = resp.json()
print(data["tls"]["ja3_hash"], data["tls"]["ja4"])
Use impersonate="chrome" (or "safari", "safari_ios") to always target the latest browser version the library knows, rather than pinning something like chrome124 that will age. Recent curl_cffi releases also ship a curl-cffi update command that refreshes the fingerprint database without a full version bump, which matters because a fingerprint that was current last quarter can drift as browsers update.
For a real crawl you want one impersonated session per identity, reused across requests, the same way you would reuse a session in requests and aiohttp async scraping:
from curl_cffi import requests
session = requests.Session(impersonate="chrome")
session.proxies = {
"http": "http://USER:PASS@gate.sparkproxy.io:10000",
"https": "http://USER:PASS@gate.sparkproxy.io:10000",
}
r1 = session.get("https://www.sparkproxy.io")
r2 = session.get("https://www.sparkproxy.io/pricing") # same TLS + cookies
If you prefer the command line or a non-Python stack, curl-impersonate itself does the same job:
curl_chrome116 https://tls.peet.ws/api/all
Go and other languages have equivalents built on the same idea (utls / tls-client), all of which rewrite the ClientHello to match a browser template.
Option 2: Drive a real browser
A genuine browser generates a genuine browser fingerprint by definition, because it is the browser. Playwright or a patched Chromium gives you a real Chrome JA3/JA4 for free, plus correct HTTP/2 and JavaScript execution. The cost is speed and memory: a browser is far heavier than an HTTP client, so this suits JavaScript-heavy targets rather than high-volume APIs.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={"server": "http://gate.sparkproxy.io:10000",
"username": "USER", "password": "PASS"},
)
page = browser.new_page()
page.goto("https://tls.peet.ws/api/all")
print(page.inner_text("body"))
browser.close()
The catch is that a real browser still leaks other automation signals (navigator.webdriver, missing plugins, headless quirks), so pair it with a stealth patch. The TLS layer is solved; the JavaScript layer is not, automatically.
Option 3: Let the SparkProxy Scraping API handle the whole stack
Matching TLS is one layer. Keeping TLS, HTTP/2, header order, and browser behavior mutually consistent, and current, across thousands of requests is the hard part. The SparkProxy Scraping API renders the target in a real browser with a browser-realistic TLS profile, so the JA3/JA4 it presents matches the headers and behavior it sends. You call one endpoint and get HTML back.
curl -X GET "https://scrape.sparkproxy.io/api/v1?url=https://example.com&render_js=true&stealth=true&country_code=US" \
-H "X-API-Key: sk-xxxxxxxxxxxxxxxx"
import requests
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "sk-xxxxxxxxxxxxxxxx"},
params={
"url": "https://example.com",
"render_js": "true",
"stealth": "true",
"country_code": "US",
},
)
print(resp.text)
render_js=true runs the page in a real browser engine, and stealth=true layers on the anti-detection profile for sites that fingerprint aggressively. Because the request leaves from SparkProxy's stack rather than your urllib3, the fingerprint your target sees is a browser's, not Python's. This is the managed-API-versus-self-managed trade-off: you give up some control and pay per request, and in return you stop maintaining impersonation templates every time Chrome ships a new TLS profile.
requests vs curl_cffi vs Real Browser
Here is how the three client types compare on the signals that decide whether you get blocked.
| Signal | `requests` (urllib3) | `curl_cffi` (impersonate) | Real browser / Scraping API |
|---|---|---|---|
| TLS fingerprint (JA3/JA4) | urllib3 default, no browser matches it | Matches a real Chrome/Safari template | Genuine browser fingerprint |
| HTTP version | HTTP/1.1 by default | HTTP/2, matched to the browser | HTTP/2 (and HTTP/3 where supported) |
| Header order | urllib3 order, not Chrome's | Browser-accurate order | Browser-accurate order |
| JavaScript execution | None | None | Full |
| JA4 SNI marker | `i` risk if you connect by IP | `d`, sends SNI | `d`, sends SNI |
| Speed | Fastest | Fast | Slowest (browser) / managed (API) |
| Best for | APIs and sites with no TLS check | Protected JSON/HTML endpoints at volume | JS-heavy sites and hard anti-bot targets |
The pattern is clear. Plain requests is fine against targets that do not fingerprint TLS, and it is the wrong tool the moment one does. curl_cffi covers most protected endpoints at high speed. A real browser or the Scraping API is what you reach for when the target also fingerprints JavaScript and behavior, not just the handshake.
Check Your Own JA3/JA4 Fingerprint
Before you trust any fix, confirm what your client actually sends. Point each client at a TLS echo service that returns the fingerprint it observed, and compare:
# Plain requests: expect a urllib3 JA3 that no browser shares
import requests
print(requests.get("https://tls.peet.ws/api/all").json()["tls"]["ja3_hash"])
# curl_cffi impersonating Chrome: expect a Chrome-family JA3/JA4
from curl_cffi import requests as cffi
print(cffi.get("https://tls.peet.ws/api/all", impersonate="chrome").json()["tls"]["ja4"])
Open the same URL in your real Chrome and compare the JA4 to what curl_cffi reports. If the a part matches (same TLS version, cipher count, extension count, ALPN) you are impersonating correctly. If your scraper's JA4 shows i where the browser shows d, you are connecting without SNI and need to fix that first. Run this check again after every major browser release, because a template that matched last month can fall behind.
Frequently asked questions
FAQ
TLS fingerprinting identifies the software making an HTTPS request by the shape of its TLS handshake. The client's ClientHello lists its supported ciphers, extensions, and curves in a client-specific order, and schemes like JA3 and JA4 hash that list into a comparable identifier. A server can tell Chrome from Python before reading any header.
A JA3 fingerprint is an MD5 hash of five ordered ClientHello fields, created by Salesforce in 2017. A JA4 fingerprint is the 2023 FoxIO successor with a readable a_b_c layout that sorts the cipher and extension lists before hashing. Sorting makes JA4 stable against Chrome's per-connection extension shuffling, which broke JA3, and JA4 also supports QUIC and HTTP/3.
No. The User-Agent is an HTTP header, and the TLS fingerprint is computed from the TLS handshake that happens before any header is sent. Changing the header while keeping a Python or Go TLS stack creates a mismatch that anti-bot systems flag rather than a disguise. You have to change the TLS layer itself with a tool like curl_cffi.
No. A proxy changes the source IP, not the ClientHello your client generates. Your JA3 and JA4 stay identical no matter how many IPs you rotate through, which is why fingerprinted automation gets grouped and blocked across a whole proxy pool. Fix the fingerprint at the client, then add proxies for IP diversity.
Often, but not always. curl_cffi gives you a browser-accurate TLS and HTTP/2 fingerprint, which clears TLS-based blocks on many protected endpoints. Sites that also fingerprint JavaScript, canvas, or mouse behavior need a real browser or a managed Scraping API on top, because an HTTP client cannot execute the page.
A strong signal is getting blocked or challenged on the very first request, with no rate-limit history and a clean IP, especially when the same request succeeds from a real browser on the same network. Confirm it by sending the request through curl_cffi with impersonate set: if the block disappears once your TLS fingerprint matches a browser, TLS fingerprinting was the cause.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Scrape Airbnb Listings and Prices
Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

How to Scrape GraphQL APIs
Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

How to Bypass reCAPTCHA When Web Scraping
How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.
