TLS fingerprinting in 2026: a complete guide for scrapers
TLS fingerprinting is the single quietest reason a scraper that worked on Tuesday returns a 403 page on Wednesday. The HTTP request looks identical, the proxy is clean, the cookies are right, and the headers match Chrome to the byte. None of it matters because Cloudflare or Akamai already classified the connection at the TLS handshake, before a single header was parsed. If your TLS fingerprint says “Python requests,” everything that comes after gets the bot treatment regardless of how careful the rest of your stack is.
This guide walks through what TLS fingerprinting actually inspects, how JA3 and JA4 are computed, what the most common scraping libraries broadcast, and which bypass tools work in 2026. Every example uses a real ClientHello captured from production traffic. By the end you will know which library to reach for when a target starts checking TLS, and how to verify your fingerprint matches a real browser before you push the change.
What a server actually sees during the TLS handshake
A TLS connection starts with the client sending a ClientHello message that announces every parameter the connection might use. That message is structured, ordered, and rich, which makes it ideal raw material for fingerprinting. The server can read the ClientHello, hash specific fields into a stable identifier, and compare that identifier against a database of known clients before responding with a single byte of HTTP.
Fields that fingerprinters care about include:
- TLS version advertised in the legacy version field plus the supported_versions extension
- Cipher suite list, in the exact order the client listed them
- Extensions list, also in order, including any GREASE values
- Supported elliptic curves under the supported_groups extension
- EC point formats under the ec_point_formats extension
- ALPN protocols, ordered (for example h2, http/1.1)
- Signature algorithms for certificate verification
- Key share and PSK key exchange modes for TLS 1.3
A real Chrome 124 ClientHello includes a deliberately randomized GREASE value at the front of the cipher list, advertises 17 cipher suites in a specific order, ships 14 extensions, and offers x25519, secp256r1, and secp384r1 in that order. A vanilla Python requests call shipping through urllib3 and OpenSSL advertises a completely different set, in a different order, with no GREASE, and that difference is enough for a fingerprinter to label the connection non-browser within microseconds.
For the IETF specification of what each field means, see RFC 8446 (TLS 1.3) and RFC 8701 for GREASE.
How JA3 is computed
JA3 was published by Salesforce engineers in 2017 and remains the most widely deployed TLS fingerprinting scheme. It hashes a comma-separated string built from the ClientHello into an MD5 digest. The string format is:
TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats
For Chrome 124 on macOS, the JA3 string looks like:
771,4865-4866-4867-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-17513,29-23-24,0
That hashes to cd08e31494f9531f560d64c695473da9, which is the JA3 of millions of legitimate browsers. The MD5 hash is the value Cloudflare logs and DataDome compares against allowlists.
For comparison, a default Python requests 2.32 call on Python 3.12 with the system OpenSSL produces JA3 string:
771,4866-4867-4865-49196-49195-52393-49199-49200-52392-49171-49172-156-157-47-53,0-11-10-35-22-23-13-43-45-51,29-23-30-25-24,0-1-2
Hashed to 2e8a3d1f2cdb6a44b1d40f3b3b89e7e0. That fingerprint is in every bot-detection database from Cloudflare to Imperva.
A minimal computation script:
import hashlib
import struct
from scapy.layers.tls.handshake import TLSClientHello
GREASE = {0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a,
0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada,
0xeaea, 0xfafa}
def ja3_string(client_hello: TLSClientHello) -> str:
version = client_hello.version
ciphers = "-".join(str(c) for c in client_hello.ciphers if c not in GREASE)
exts = "-".join(str(e.type) for e in client_hello.ext if e.type not in GREASE)
curves = "-".join(str(g) for g in client_hello.supported_groups
if g not in GREASE)
fmts = "-".join(str(f) for f in client_hello.point_formats)
return f"{version},{ciphers},{exts},{curves},{fmts}"
def ja3_hash(s: str) -> str:
return hashlib.md5(s.encode()).hexdigest()
Note the GREASE filtering. RFC 8701 specifies that browsers will randomly insert reserved values to ensure intermediaries do not start enforcing strict lists. Servers that compute JA3 strip GREASE before hashing, otherwise the same browser would produce a new fingerprint every connection.
How JA4 is computed and why it replaced JA3 for serious shops
JA4, published by FoxIO in 2023, is what most modern bot-detection vendors moved to during 2024 and 2025. It fixes three real problems with JA3:
- JA3 used MD5, which collides under certain ordering tricks. JA4 uses SHA-256 truncated to 12 hex characters.
- JA3 was sensitive to extension order, which Chrome started randomizing in version 110. That broke JA3 for fresh Chrome installs. JA4 sorts extensions before hashing.
- JA3 had no readable prefix. JA4 prefixes the hash with a human-readable summary, so an analyst can see at a glance that a connection is
t13d1516h2_8daaf6152771_b186095e22b6and decode TLS 1.3, 15 ciphers, 16 extensions, ALPN h2.
A JA4 has three parts separated by underscores:
- Prefix: protocol (t for TLS, q for QUIC), version, SNI presence (d for domain, i for IP), cipher count, extension count, first ALPN
- Cipher hash: SHA-256 of sorted cipher list, truncated to 12 hex
- Extension hash: SHA-256 of sorted extension list plus signature algorithms, truncated to 12 hex
| field | JA3 | JA4 |
|---|---|---|
| hash function | MD5 | SHA-256 truncated |
| extension order | strict | sorted |
| GREASE handling | stripped | stripped |
| readable prefix | none | yes |
| signature algorithms | not included | included in extension hash |
| QUIC support | no | yes (q prefix) |
For a complete reference of the JA4+ family (which also includes JA4S for server, JA4H for HTTP, JA4L for latency), see the FoxIO JA4 specification.
What common scraping libraries broadcast in 2026
Different libraries produce different fingerprints because each one builds the ClientHello via a different TLS implementation. Here is a snapshot of what production targets see when each tool connects:
| client | TLS library | typical JA4 prefix | bot risk |
|---|---|---|---|
| Python requests 2.32 | OpenSSL via stdlib | t13d1715h2 | very high, well-known |
| httpx with default | OpenSSL via stdlib | t13d1715h2 | very high, identical to requests |
| Node.js fetch | Node TLS | t13d1316h2 | high, distinct from browsers |
| Go net/http | Go crypto/tls | t13d2014h2 | high, classic Go fingerprint |
| curl 8.x | OpenSSL | t13d1314h2 | medium, common in dev tools |
| Chrome 124 stable | BoringSSL | t13d1516h2 | safe, real browser |
| Firefox 124 | NSS | t13d1715h2 | safe, real browser |
| curl_cffi | impersonates Chrome | t13d1516h2 | safe if version-matched |
| tls-client (Python) | uTLS via Go | t13d1516h2 | safe if version-matched |
| Playwright with Chromium | BoringSSL | t13d1516h2 | safe, identical to Chrome |
| Playwright with Firefox | NSS | t13d1715h2 | safe, identical to Firefox |
The tools that score “safe” are not safe because of magic. They are safe because they generate a ClientHello that is byte-identical to a real browser at the TLS layer. If you switch from requests to curl_cffi and target Chrome 124, you replace your stdlib OpenSSL handshake with one that matches BoringSSL exactly.
Bypass approach 1: curl_cffi for Python
curl_cffi is the most popular Python solution in 2026 because it leverages curl’s --impersonate mode, which itself uses a patched libcurl that produces ClientHellos matching specific browser versions. It is a drop-in for requests with a few extra parameters.
from curl_cffi import requests
resp = requests.get(
"https://target.example.com/api/products",
impersonate="chrome124",
proxies={"https": "http://user:pass@proxy.example.com:8080"},
timeout=30,
)
print(resp.status_code, resp.headers.get("cf-ray"))
The impersonate="chrome124" parameter tells curl_cffi to use the Chrome 124 ClientHello template. Other available targets in mid-2026 include chrome116, chrome120, chrome124, safari17, safari17_2_ios, firefox124, and edge124. Match the impersonation target to whatever browser you are claiming to be in the User-Agent header. A fingerprint that says Chrome but a User-Agent that says Firefox is itself a flag.
A common pitfall: the default Python TLS context overrides curl_cffi if you use requests.Session() from the stdlib instead of curl_cffi.requests.Session(). Make sure every call goes through the curl_cffi import, not standard requests.
Bypass approach 2: tls-client (uTLS-backed)
tls-client wraps Bogdanfinn’s tls-client Go library, which itself uses uTLS to forge ClientHellos. It supports more profiles than curl_cffi and is the preferred choice when you need fine-grained control over individual fields.
import tls_client
session = tls_client.Session(
client_identifier="chrome_124",
random_tls_extension_order=True,
)
resp = session.get(
"https://target.example.com/checkout",
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
},
proxy="http://user:pass@proxy.example.com:8080",
)
random_tls_extension_order=True matches Chrome 110+ behavior of shuffling extension order on every connection. This is critical against JA3-based fingerprinters that have not migrated to JA4 yet, because the static order from older tls-client versions was itself becoming a flag.
For high-volume operations, build a pool of tls_client.Session instances each pinned to a different profile (chrome_124, safari_ios_17, firefox_124) and rotate through them. This naturally diversifies your TLS footprint without changing any other code.
Bypass approach 3: full browser via Playwright or Stagehand
When the target inspects more than just TLS (canvas, WebGL, audio, behavioral), the cheapest correct answer is to ship a real browser. Playwright with Chromium produces a TLS fingerprint that matches Chrome by definition because it is Chrome under the hood.
from playwright.async_api import async_playwright
async def fetch(url, proxy):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
proxy={"server": proxy["server"], "username": proxy["user"], "password": proxy["pass"]},
args=["--disable-blink-features=AutomationControlled"],
)
ctx = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
viewport={"width": 1920, "height": 1080},
locale="en-US",
)
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle")
html = await page.content()
await browser.close()
return html
Playwright costs roughly 200-400ms more per page than curl_cffi, plus 80-150 MB of RAM per active context. For sites where TLS is the only check, prefer the lighter approach. For sites with multi-layer fingerprinting (most enterprise targets in 2026), the real browser is the path of least resistance. See our notes on JavaScript-heavy SPA scraping with AI agents for related browser orchestration patterns.
Verifying your fingerprint before you trust it
Never assume your TLS impersonation works without testing. The two best public verifiers are tls.peet.ws and ja4db.com, both of which return your live JA3 and JA4 hashes in JSON. Pipe your client through them and compare the output against a known-good Chrome run from the same proxy.
import json
from curl_cffi import requests
resp = requests.get(
"https://tls.peet.ws/api/all",
impersonate="chrome124",
)
data = resp.json()
print("JA3:", data["ja3"])
print("JA3 hash:", data["ja3_hash"])
print("JA4:", data["ja4"])
print("Akamai:", data["akamai_fingerprint"])
print("HTTP/2:", data["http2"]["sent_frames"])
If your hashes do not match the reference Chrome 124 hashes published on the FoxIO repo, your impersonation is broken. Most often this is because of an outdated curl_cffi version (each Chrome stable release shifts the fingerprint slightly), an OS-level OpenSSL override, or a transparent proxy in your network rewriting the handshake. Fix all three before scaling up.
For more on validating header authenticity to match your TLS profile, see header rotation and TLS profiles for production scrapers.
Operational checklist for production scrapers
When you ship a TLS-aware scraper, the following checks should be in your CI or monitoring:
- Pin the impersonation profile to a real browser version that exists in the wild (not “chrome_latest” which can drift)
- Refresh the impersonation library quarterly to keep up with browser releases
- Verify against tls.peet.ws or equivalent on every deploy
- Match TLS profile to User-Agent (Chrome impersonation, Chrome User-Agent)
- Match TLS profile to ALPN behavior (h2 for modern browsers, not http/1.1)
- Match TLS profile to HTTP/2 settings frame (window size, header table size)
- Avoid using the same profile across thousands of concurrent connections from one IP, that itself becomes a fingerprint
- Log the JA4 of every outbound request so you can audit drift after a library update
The last point matters more than scrapers usually realize. If your TLS library upgrades silently and starts producing a new JA4, your block rate can quintuple in a day with no other change in the codebase. Logging fingerprints lets you correlate block-rate spikes against library versions instead of hunting blind.
Common failure modes and how to debug them
- Random 403s on a small fraction of requests: usually means GREASE values or extension order are not being randomized. Switch to
random_tls_extension_order=Trueor upgrade curl_cffi. - Consistent 403 within minutes of starting: the TLS profile probably does not match the claimed browser. Re-verify against tls.peet.ws and align User-Agent, ALPN, and TLS profile.
- Works locally, fails in Docker: alpine-based images often ship a different OpenSSL build that overrides curl_cffi’s bundled libcurl. Use a glibc-based image like
python:3.12-slim. - Works in Docker, fails on Lambda: AWS Lambda’s runtime environment can replace the TLS stack entirely. Bundle a static curl_cffi build or use a layer pre-built for Lambda.
- Works for a week, then starts failing: vendor updated their detection rules to require JA4 instead of JA3, and your library has not been updated. Refresh the library.
- Cloudflare flips from 200 to challenge: site rolled out Turnstile or moved to “Under Attack” mode. TLS alone will not solve it. See our Cloudflare Turnstile bypass tactics guide.
Browser TLS evolution: what to expect through 2026 and 2027
Chrome and Firefox release on six-week cycles. Each release ships small TLS changes, sometimes adding a new extension, sometimes deprecating a cipher. The pace is fast enough that pinning to a specific version like Chrome 124 will start drifting from market share within three months as Chrome 126 and 128 roll out. By six months, your impersonation target is a minority of the traffic on the web, and that itself becomes anomalous.
The pragmatic approach is to follow Chrome stable. Set up an automated job that checks for new curl_cffi or tls-client releases that add a Chrome version, run regression tests against your top 20 target sites, and roll the profile forward when those tests pass. Most teams do this quarterly because Chrome enterprise customers tend to lag stable by two quarters anyway, so the long tail of legitimate Chrome traffic gives you cover.
QUIC and HTTP/3 are also showing up on more endpoints. Cloudflare and Google serve HTTP/3 to clients that advertise it via Alt-Svc, and JA4 has a q prefix for QUIC connections specifically. If your impersonation library does not support QUIC yet, you fall back to HTTP/2, which is a slight anomaly compared to Chrome’s behavior of preferring QUIC when available. None of the major impersonation libraries fully support QUIC fingerprinting in mid-2026. This is a coming gap to watch.
FAQ
Q: do I need TLS impersonation if I am using a real headless browser?
No. Headless Chromium produces a real Chrome ClientHello at the TLS layer because it uses BoringSSL. The TLS fingerprint is identical to a regular Chrome install. The fingerprinting risks for headless browsers live in canvas, WebGL, and behavioral signals, not TLS.
Q: will switching to TLS 1.3 alone help?
No. TLS 1.3 is what real browsers use. Switching to it removes one anomaly but does not solve the field-order and extension-set mismatch that fingerprinters key on. You still need to impersonate the full ClientHello.
Q: how often do JA3/JA4 hashes change for a given browser?
Every Chrome stable release shifts the JA4 slightly. Major releases (every 4 weeks) almost always change something. Minor releases (every 1-2 weeks) change less often. Plan on refreshing your profiles every 6-12 weeks to stay current.
Q: can a target ban me by JA4 alone?
In theory yes. In practice no enterprise scraper-target uses JA4 as a sole signal because it would also block a meaningful fraction of legitimate users on older browsers. JA4 is one input into a risk score, not a hard ban. That said, an unusual JA4 plus other anomalies will trip the score quickly.
Q: does using a residential proxy help with TLS fingerprinting?
A clean residential IP buys you tolerance for marginal fingerprints. A dirty datacenter IP gets blocked even with a perfect Chrome impersonation. The two factors compound. Always pair good TLS hygiene with appropriate proxy quality.
Common pitfalls in production
The first pitfall most teams hit is library version skew between staging and production. A pip install curl_cffi on a fresh staging container pulls 0.7.x with Chrome 124 templates, while production was pinned to 0.6.x with Chrome 116 templates eight months ago. The two produce different JA4 hashes (t13d1516h2_8daaf6152771_b186095e22b6 versus t13d1714h2_5b57614c22b0_3d5424432f57), and the production target has since allowlisted only the Chrome 124 cipher hash. Pin the curl_cffi version in requirements.txt and refresh deliberately rather than letting pip install --upgrade drift the fingerprint silently.
The second pitfall is forgetting that HTTP/2 settings frames are themselves fingerprinted. Akamai’s BMP and Cloudflare both compute a separate hash over the SETTINGS frame values: HEADER_TABLE_SIZE (Chrome ships 65536), ENABLE_PUSH (0), MAX_CONCURRENT_STREAMS (1000), INITIAL_WINDOW_SIZE (6291456), and MAX_HEADER_LIST_SIZE (262144), with a specific WINDOW_UPDATE increment of 15663105 immediately after. If your library produces a perfect ClientHello but ships SETTINGS in the order [ENABLE_PUSH, MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, MAX_HEADER_LIST_SIZE, HEADER_TABLE_SIZE], the akamai_fingerprint score on tls.peet.ws will not match Chrome and you will eat 403s on Akamai-protected sites regardless of TLS hygiene.
The third pitfall is fingerprint collision under high concurrency. If you launch 200 worker processes each running curl_cffi pinned to chrome124, all 200 connections hit the target with the identical JA4 within milliseconds. Real browsers shuffle extension order and ship slightly different GREASE values per connection. Set random_tls_extension_order=True and rotate across at least three impersonation profiles per pool, otherwise the velocity itself becomes the signal that overrides perfect per-connection mimicry.
Wrapping up
TLS fingerprinting moved from advanced anti-bot to baseline in 2024 and is now table stakes in 2026. If you are running anything bigger than a tinkering project, your stack needs a way to produce real-browser ClientHellos, a way to verify those hellos against a public reference, and a way to log them so you can debug drift. curl_cffi and tls-client cover most cases for Python, and Playwright covers the rest. Browse the anti-detect-browsers category on DRT for related guides on canvas and WebGL spoofing, and pair this guide with a serious look at proxy quality before committing to any single approach.