Your cart is currently empty!
Category: Anti-Detect Browser Tutorials
-
Header rotation and TLS profiles for production scrapers
Header rotation and TLS profiles for production scrapers
Header rotation and TLS profiles are the two halves of looking like a real browser at the network layer. Either one alone is detectable. Header rotation without TLS alignment ships Chrome-style headers over a Python TLS handshake, which is an obvious mismatch. TLS impersonation without header alignment ships a perfect Chrome ClientHello followed by Python’s idiosyncratic header order, which is also obvious. The two must move together for a scraper to look genuinely like a browser to enterprise bot detection.
This guide covers what real Chrome and real Firefox headers look like in 2026, how to align headers with TLS profiles, common rotation patterns, and the production code that ties it all together. Everything below targets curl_cffi and tls-client because those are the two libraries that handle both surfaces, but the principles apply to any scraping stack.
Why headers and TLS must align
Bot detection vendors compute TLS fingerprints (JA4) at the connection layer and header fingerprints (header order, presence of specific headers, casing) at the request layer. The vendor’s risk model checks consistency across these signals: a Chrome 124 JA4 with Chrome-style headers in the right order produces low risk. A Chrome 124 JA4 with Python-style headers (different order, missing headers, extra headers) produces high risk because the inconsistency is itself anomalous.
What changes between real browsers:
browser distinct signals Chrome 124 header order: User-Agent late; specific X-Client-Data on first request to Google domains; sec-ch-ua presence Firefox 124 header order: User-Agent first; no sec-ch-ua; different Accept-Encoding values Safari 17 sec-fetch- headers but slight differences from Chrome; no sec-ch-ua- Edge 124 nearly identical to Chrome but X-Edge-Client-Data on Microsoft domains A scraper using Chrome TLS impersonation must also ship Chrome’s exact header order. A Firefox-impersonating scraper needs Firefox’s headers. Mixing them creates a third profile that matches no real browser, which is the worst of both worlds.
For the IETF reference on HTTP semantics, see RFC 9110, which defines what headers mean but not what order they appear in. Order is implementation-specific, which is exactly why it is fingerprintable.
What real Chrome 124 headers look like
A captured request from Chrome 124 stable to a public site:
GET /products HTTP/2 Host: example.com sec-ch-ua: "Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99" sec-ch-ua-mobile: ?0 sec-ch-ua-platform: "Windows" upgrade-insecure-requests: 1 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,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 sec-fetch-site: none sec-fetch-mode: navigate sec-fetch-user: ?1 sec-fetch-dest: document accept-encoding: gzip, deflate, br, zstd accept-language: en-US,en;q=0.9 priority: u=0, iCritical observations:
- All headers are lowercase (HTTP/2 requires lowercase pseudo-headers and Chrome lowercases everything else)
sec-ch-uagroup comes beforeuser-agentacceptcomes afteruser-agentsec-fetch-*group comes afteracceptaccept-encodingincludeszstd(Chrome 124+)priorityheader is present (Chrome 124 uses RFC 9218 signaling)
A subsequent same-origin navigation has slightly different
sec-fetch-*values:sec-fetch-site: same-origin sec-fetch-mode: navigate sec-fetch-user: ?1 sec-fetch-dest: document referer: https://example.com/For an API call (XHR/fetch from page JavaScript):
sec-fetch-site: same-origin sec-fetch-mode: cors sec-fetch-dest: empty accept: */* accept-language: en-US,en;q=0.9 content-type: application/jsonThese contextual differences are themselves fingerprinted. A scraper that ships
sec-fetch-mode: navigatefor an API endpoint is anomalous.What real Firefox 124 headers look like
Firefox 124 ships headers in a noticeably different shape:
GET /products HTTP/2 Host: example.com user-agent: Mozilla/5.0 (Windows NT 10.0; rv:124.0) Gecko/20100101 Firefox/124.0 accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 accept-language: en-US,en;q=0.5 accept-encoding: gzip, deflate, br upgrade-insecure-requests: 1 sec-fetch-dest: document sec-fetch-mode: navigate sec-fetch-site: none sec-fetch-user: ?1 priority: u=0, iKey differences from Chrome:
user-agentcomes first (after Host)- No
sec-ch-ua-*headers (Firefox does not implement Client Hints) accept-languageusesq=0.5(Chrome usesq=0.9)accept-encodingdoes not includezstd(Firefox added it in 126)- Header casing is preserved as-sent (lowercase in HTTP/2)
A scraper claiming to be Firefox must ship these specific headers in this order. Chrome-style sec-ch-ua headers from a Firefox profile is a flag.
What real Safari 17 headers look like
Safari is more conservative:
GET /products HTTP/2 Host: example.com accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 sec-fetch-site: none sec-fetch-dest: document accept-language: en-US,en;q=0.9 sec-fetch-mode: navigate accept-encoding: gzip, deflate, br user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15Differences from Chrome:
acceptis shorter (noimage/avif, noapplication/signed-exchange)- No
sec-ch-ua-* - No
upgrade-insecure-requests - No
priority user-agentcomes afteracceptandsec-fetch-*headers
Safari mobile (iOS) is shorter still:
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 accept-language: en-US,en;q=0.9 accept-encoding: gzip, deflate, br user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1Each browser’s header set is distinct enough to fingerprint independently of TLS. Match them.
Header rotation strategies
Two patterns work in production:
Pattern 1: profile pool. Maintain a pool of complete browser profiles (Chrome 122, Chrome 124, Firefox 124, Safari 17, Edge 124). Each profile has a matched TLS impersonation, header set, and User-Agent. Rotate across the pool by request.
import random from curl_cffi import requests PROFILES = [ { "tls": "chrome124", "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "sec_ch_ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', "platform": "Windows", }, { "tls": "chrome124", "ua": "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", "sec_ch_ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', "platform": "macOS", }, { "tls": "firefox124", "ua": "Mozilla/5.0 (Windows NT 10.0; rv:124.0) Gecko/20100101 Firefox/124.0", "sec_ch_ua": None, # Firefox does not send this "platform": "Windows", }, { "tls": "safari17", "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.4 Safari/605.1.15", "sec_ch_ua": None, "platform": "macOS", }, ] def build_headers(profile, url): h = {} if profile["sec_ch_ua"]: h["sec-ch-ua"] = profile["sec_ch_ua"] h["sec-ch-ua-mobile"] = "?0" h["sec-ch-ua-platform"] = f'"{profile["platform"]}"' h["user-agent"] = profile["ua"] h["accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" h["sec-fetch-site"] = "none" h["sec-fetch-mode"] = "navigate" h["sec-fetch-dest"] = "document" h["accept-encoding"] = "gzip, deflate, br, zstd" if "Chrome" in profile["ua"] else "gzip, deflate, br" h["accept-language"] = "en-US,en;q=0.9" return h def fetch_with_random_profile(url, proxies=None): profile = random.choice(PROFILES) headers = build_headers(profile, url) return requests.get(url, headers=headers, impersonate=profile["tls"], proxies=proxies)Pattern 2: stable profile per session. Pick a profile when you start a scraping session and stick with it for the duration. This is more realistic because a single user does not switch browsers mid-session.
class ScraperSession: def __init__(self, profile=None, proxy=None): self.profile = profile or random.choice(PROFILES) self.proxy = proxy self.cookies = {} def fetch(self, url, **kwargs): headers = build_headers(self.profile, url) headers.update(kwargs.get("headers", {})) return requests.get( url, headers=headers, impersonate=self.profile["tls"], proxies={"https": self.proxy} if self.proxy else None, cookies=self.cookies, )For most scraping, pattern 2 is more authentic. Per-request profile rotation creates an unusual session shape (one user, multiple browsers).
Header order matters more than header values
Most scrapers focus on header values (User-Agent, Accept, etc.) and ignore order. Bot detection vendors increasingly check order because order is harder to fake.
Default Python
requestsproduces this order:User-Agent Accept-Encoding Accept ConnectionDefault Chrome:
sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform upgrade-insecure-requests user-agent accept sec-fetch-site sec-fetch-mode sec-fetch-user sec-fetch-dest accept-encoding accept-language priorityThe orders are completely different. Even if you set every Chrome header in your
requestscall, requests sorts them alphabetically before sending, breaking the fingerprint.curl_cffiandtls-clientboth preserve header insertion order by default. Use them to control order. Incurl_cffi:from curl_cffi import requests # Headers are sent in the order you provide them headers = [ ("sec-ch-ua", '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'), ("sec-ch-ua-mobile", "?0"), ("sec-ch-ua-platform", '"Windows"'), ("upgrade-insecure-requests", "1"), ("user-agent", "Mozilla/5.0 ..."), ("accept", "text/html,..."), ("sec-fetch-site", "none"), ("sec-fetch-mode", "navigate"), ("sec-fetch-user", "?1"), ("sec-fetch-dest", "document"), ("accept-encoding", "gzip, deflate, br, zstd"), ("accept-language", "en-US,en;q=0.9"), ("priority", "u=0, i"), ] resp = requests.get(url, headers=dict(headers), impersonate="chrome124")curl_cffi preserves Python dict insertion order (Python 3.7+ dicts are ordered) when shipping headers. Verify with a wire capture or with tls.peet.ws’s
http_headersfield.Comparison: header sets across browsers
For a full request, what each browser ships:
header Chrome 124 Firefox 124 Safari 17 user-agent yes yes yes accept full medium short accept-language q=0.9 q=0.5 q=0.9 accept-encoding gzip,deflate,br,zstd gzip,deflate,br gzip,deflate,br sec-ch-ua yes no no sec-ch-ua-mobile yes no no sec-ch-ua-platform yes no no sec-fetch-site yes yes yes sec-fetch-mode yes yes yes sec-fetch-user yes yes sometimes sec-fetch-dest yes yes yes upgrade-insecure-requests yes yes no priority yes yes no Match every header to the claimed browser. Missing a header that the browser sends is a flag, sending one that the browser does not is also a flag.
Validating your headers
Public sites that show what headers you sent:
site shows httpbin.org/headers echo of all headers received tls.peet.ws/api/all full TLS + HTTP fingerprint including header order browserleaks.com/ip IP, headers, fingerprint summary Check that your scraper’s output at httpbin.org/headers matches what a real Chrome shows when visiting the same site. If your scraper’s headers differ in order or set, fix them.
Production header refresh cycle
Real browsers ship updates every 4-6 weeks. Each release can change:
- User-Agent string
- Sec-CH-UA brand list
- Accept-Encoding (e.g., adding zstd)
- Accept value structure
- Priority signaling
Your scraper’s profile pool needs the same refresh cadence. Plan a quarterly review:
- Pull latest stable Chrome, Firefox, Safari User-Agents from a real install or from useragents.io
- Capture latest header set from each browser via mitmproxy or DevTools
- Update profile definitions
- Verify with tls.peet.ws and httpbin.org/headers
- Run regression tests against your top 20 target sites
- Roll out the new profile pool
Without this cycle, your scraper drifts: claiming to be Chrome 122 when Chrome is on 130 means the User-Agent is itself anomalous, even with perfect TLS.
For broader scraping infrastructure patterns, see building a custom rotating proxy pool with Squid and self-hosted proxy infrastructure.
Common header mistakes
- Setting User-Agent only: leaves all other headers as Python defaults, easy to detect
- Wrong Accept value: Chrome’s Accept is distinctive; Python’s default is bare
*/* - Including X-Forwarded-For unless you really need to: trips proxy detection
- Missing sec-fetch-*: real browsers always send these for navigations
- Sec-CH-UA on a Firefox-claimed UA: only Chrome and Edge send this
- Priority header on Safari claim: Safari does not send this
- HTTP/1.1-style Connection: keep-alive in HTTP/2 requests: HTTP/2 has no Connection header
Header rotation for API scraping vs page scraping
API endpoints often have looser header expectations because real browser code (XHR, fetch) sends different headers than navigations. For an API call:
api_headers = { "user-agent": profile["ua"], "accept": "*/*", # XHR default "accept-language": "en-US,en;q=0.9", "accept-encoding": "gzip, deflate, br, zstd", "sec-ch-ua": profile["sec_ch_ua"], "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": f'"{profile["platform"]}"', "sec-fetch-site": "same-origin", "sec-fetch-mode": "cors", "sec-fetch-dest": "empty", "referer": "https://target.example.com/", "origin": "https://target.example.com", "content-type": "application/json", # for POST }Note
sec-fetch-mode: corsandsec-fetch-dest: emptyfor XHR vsnavigateanddocumentfor page loads. Match the header set to the request type.Operational checklist
- Use curl_cffi or tls-client (libraries that preserve header order)
- Maintain a profile pool with TLS + header set per profile
- Match TLS impersonation to claimed User-Agent
- Validate header order with tls.peet.ws or wire capture
- Refresh profiles quarterly with current browser versions
- Use page-style headers for navigations, XHR-style for APIs
- Set Origin and Referer correctly for cross-origin POSTs
- Avoid sending headers that real browsers do not send (X-Forwarded-For, X-Real-IP, custom defaults from your library)
- Verify against httpbin.org/headers in CI
FAQ
Q: do I need to match every header exactly?
The major signals (User-Agent, Sec-CH-UA presence, Accept value, Accept-Encoding, header order) matter most. Minor details (specific quality values in Accept-Language) matter less but cumulatively add up.Q: how often do real browsers change headers?
Major changes (new headers, removed headers) happen every few major versions. Minor changes (User-Agent string, brand list) happen every release. Plan to refresh quarterly to stay current.Q: can I use a single User-Agent for all my scrapers?
Within a session yes, across sessions no. Vendors fingerprint repeated User-Agents from the same IP space and treat them as a coordinated bot fleet. Rotate User-Agent across sessions but keep it stable within one.Q: does header casing matter?
In HTTP/1.1 servers are case-insensitive but capture original casing. Chrome lowercases all custom headers. Capitalize-Each-Word style is a Pythonrequestsdefault that flags scrapers. In HTTP/2 lowercase is required.Q: what about cookies?
Cookies are headers but with their own logic. Manage them via session cookie jars rather than as raw headers. The order is enforced by the cookie jar, not by your code.Common pitfalls in production header alignment
The first failure mode is the
priorityheader value mismatch. Chrome 124 sendspriority: u=0, ifor top-level navigations andpriority: u=1, ifor subresources, but Chrome 126+ stable started omitting theiparameter in some configurations. If you pin a Chrome 124 profile but your scraping fleet visits sites with strict server-push HTTP/2 deployments, the priority value gets compared against the User-Agent’s expected behavior. A scraper claiming Chrome 126 withu=0, iis anomalous because real Chrome 126 sendsu=0only. Update the priority value when you bump the profile’s claimed Chrome version.The second pitfall is the
accept-encodingorder on Brotli vs zstd handshakes. Chrome 124 advertisesgzip, deflate, br, zstdin that exact order. If your library reorders togzip, deflate, zstd, br(a common bug in older curl_cffi releases), Cloudflare’s content-encoding negotiation logs the alphabetical order as anomalous because alphabetical-sort is what Pythonrequestsproduces by default. Servers respond identically (they pick br or zstd regardless of order), but the fingerprint differs. Verify with a wire capture that your accept-encoding string matches Chrome byte-for-byte, including the spaces after commas.The third pitfall is referer policy mismatch on cross-origin POSTs. Chrome 124 honors a
Referrer-Policy: strict-origin-when-cross-origindefault, which means a POST fromhttps://app.example.com/checkouttohttps://api.example.com/v1/chargeshipsreferer: https://app.example.com/(origin only, no path). A scraper that hardcodes the full URL as referer (referer: https://app.example.com/checkout) violates the policy that real Chrome would have applied, which is itself a flag for vendors that compute the expected referer from the page URL plus the policy. Compute referer dynamically based on the claimed origin policy, not by copying the page URL verbatim.Real-world example: alignment-driven recovery on Akamai
A scraper team running a 40-node Playwright fleet against an Akamai-protected airline booking site experienced a sudden block-rate jump from 8 percent to 71 percent over 48 hours with no code changes. The culprit was a transparent proxy upgrade upstream that started rewriting the
accept-languageheader fromen-US,en;q=0.9toen-US,en;q=0.9,en-CA;q=0.8(the proxy added a regional fallback). Chrome 124 never sends en-CA, so the modified header diverged from any plausible Chrome profile. Akamai’s header-shape model flagged it within hours of the proxy rollout.The fix involved two parts: bypass the upstream proxy for header-sensitive requests and add a CI check that captures outbound headers via a passive sniffer and diffs them against the canonical Chrome reference:
import json import subprocess CANONICAL_CHROME_124 = { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "en-US,en;q=0.9", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1", } def diff_headers(actual: dict, canonical: dict) -> dict: diffs = {} for k, v in canonical.items(): if actual.get(k) != v: diffs[k] = {"expected": v, "actual": actual.get(k)} return diffs # In CI: run scraper against httpbin, capture, diff result = subprocess.check_output( ["python", "scrape_one.py", "https://httpbin.org/headers"] ) captured = json.loads(result)["headers"] diffs = diff_headers({k.lower(): v for k, v in captured.items()}, CANONICAL_CHROME_124) assert not diffs, f"Header drift: {json.dumps(diffs, indent=2)}"After deployment of the CI check, the team caught two more upstream-proxy-induced drifts within the next quarter before they reached production scrapers. The lesson: header alignment is not a one-time setup, it is an ongoing surveillance task because anything between your code and the wire can rewrite headers without telling you.
Comparison: header order across libraries
A wire-capture comparison of how each Python HTTP client orders the headers you provide:
library preserves dict insertion order preserves list-of-tuples order normalizes case Python requests 2.32 partial (some headers reordered) no yes (Title-Case) httpx 0.27 yes yes partial (lowercase in HTTP/2) aiohttp 3.10 yes yes yes (lowercase in HTTP/2) curl_cffi 0.7 yes yes preserves as-given tls-client 1.6 requires explicit order list yes preserves as-given urllib3 2.x partial no Title-Case Playwright page.request matches Chrome exactly n/a lowercase (HTTP/2) Selenium WebDriver matches browser exactly n/a depends on browser For full control, use curl_cffi or tls-client with explicit ordering. For zero effort, use Playwright’s
page.requestwhich matches the launched browser. Anything else introduces unpredictable order that requires per-library workarounds.Detection in production logs: header-shape correlation
When you suspect a target is fingerprinting headers, you can confirm by correlating block rate against header changes. Log every outbound header set with a stable hash:
import hashlib import json def header_shape_hash(headers: dict) -> str: # Hash on the ordered keys, not the values, to capture shape keys_in_order = list(headers.keys()) return hashlib.sha256(json.dumps(keys_in_order).encode()).hexdigest()[:8] def log_request(url: str, headers: dict, status: int): shape = header_shape_hash(headers) print(json.dumps({ "url": url, "header_shape": shape, "status": status, }))Aggregate by
header_shapeover a 24h window. If one shape has a 90 percent success rate and another has a 30 percent success rate, the difference is your fingerprint. Either pin the high-success shape or investigate why the low-success shape is leaking. This kind of shape-vs-status correlation is invisible without the structured logging.Wrapping up
Header rotation and TLS profiles are two halves of the same problem. Get them aligned and your scraper looks like a real browser at the network layer. Get them mismatched and you broadcast “Python pretending to be Chrome” to every modern bot detector. The fix is a profile pool, a library that preserves header order (curl_cffi, tls-client), quarterly profile refreshes, and validation in CI. Pair this with our TLS fingerprinting guide and HTTP/2 fingerprinting writeups for the full network-layer picture, and browse the anti-detect-browsers category on DRT for related tactics.
-
DataDome vs PerimeterX vs Akamai bot management compared
DataDome vs PerimeterX vs Akamai bot management compared
DataDome vs PerimeterX vs Akamai is the comparison every scraper team faces eventually. By 2026 these three vendors plus Cloudflare cover the majority of enterprise bot defense deployments. They share many techniques (TLS fingerprinting, behavioral signals, JavaScript challenges) but differ in emphasis, deployment patterns, and bypass difficulty. Knowing which vendor protects your target shapes your tooling choice, your proxy budget, and your success rate.
This guide breaks down each vendor’s actual detection layers, common deployment configurations, observed bypass difficulty in 2026, and tooling recommendations. The benchmarks are based on real scraper success rates across hundreds of target sites measured during early 2026, not vendor marketing claims.
What each vendor sells
A short orientation:
vendor category deployment typical price DataDome bot management edge service or on-prem $$$ enterprise PerimeterX (Human Security) bot management + fraud edge service $$$$ enterprise Akamai Bot Manager bot management Akamai CDN add-on $$$$ enterprise Cloudflare Bot Management bot management Cloudflare CDN add-on $$ to $$$$ tiered Cloudflare is the volume leader because its CDN hosts a huge fraction of the web. DataDome targets enterprise ecommerce and ticketing. PerimeterX (rebranded as Human Security after 2022) targets enterprises with fraud concerns alongside scraping. Akamai Bot Manager is Akamai’s add-on for their CDN customers, predominantly Fortune 500 sites.
For each vendor’s official marketing pages, see DataDome, Human Security (formerly PerimeterX), and Akamai Bot Manager.
Detection layers, side by side
A simplified layer-by-layer comparison:
layer DataDome PerimeterX Akamai TLS fingerprint (JA3/JA4) logged, weighted logged, weighted logged, weighted HTTP/2 fingerprint weighted (proprietary hash) weighted core signal (Akamai H2 hash) Header order and values core signal weighted weighted Browser fingerprint (canvas, WebGL, audio) core signal core signal core signal Behavioral (mouse, scroll, timing) weighted core signal (very heavy) weighted IP reputation weighted weighted weighted Proxy/VPN detection yes yes yes JavaScript challenge optional, varies by site mandatory in most deployments optional Mobile SDK fingerprint yes yes yes Device persistence (cookie) yes yes yes The key difference: PerimeterX leans heaviest on behavioral signals because Human Security’s broader product line is fraud-focused, and behavior is the strongest predictor of fraud intent. DataDome leans heavily on header and request shape signals because it ships into ecommerce environments where bot patterns are well-characterized. Akamai weights HTTP/2 and TLS heavily because its CDN-edge position lets it inspect the network layer cheaply.
DataDome: deep dive
DataDome positions itself as a “real-time bot management” service. Common deployments protect:
- Ecommerce checkout and pricing pages
- Travel and hospitality booking funnels
- Ticketing sites
- Job boards (against scraping competitors)
- Lead-gen and SaaS sign-up flows
What scrapers actually face:
- Header inspection: DataDome checks header order and presence. Default Python
requestsproduces a header order distinct from Chrome. DataDome flags this within microseconds. - TLS and HTTP/2 fingerprinting: standard JA4 + Akamai H2 checks.
- Browser fingerprint: canvas, WebGL, audio, font enumeration. Their JS captures all of these.
- JavaScript challenge: a heavy minified script (~70KB) that exercises Web APIs in patterns. Failing the challenge means you do not get the
datadomecookie that subsequent requests need. - Behavioral signals: lighter than PerimeterX but still present. Mouse and scroll patterns feed into the score.
- CAPTCHA fallback: if score is low, the user gets a slider CAPTCHA (geetest-style or DataDome’s own).
Bypass difficulty in 2026: medium-high. With patchright + clean residential proxy + humanization, success rates around 75-90%. Without those, near zero.
Tooling that works against DataDome:
- patchright + Playwright + clean residential proxy
- curl_cffi for API endpoints (no JavaScript challenge required)
- Browserbase or similar managed browser services
- Third-party CAPTCHA solver for the fallback slider
For specific DataDome bypass tactics, the JavaScript challenge is the chokepoint. If you do not execute it, you do not get the cookie, and every subsequent request fails. Real browsers handle this naturally. Headless tools without full JS engines (curl, requests, basic httpx) cannot.
PerimeterX: deep dive
PerimeterX (now Human Security) is the most behavior-heavy of the three. Their deployments often emphasize fraud prevention as much as scraping prevention. Common targets:
- Sneaker drop sites (Snkrs, Confirmed)
- Ticketing platforms
- Streaming services (account creation)
- Banking and fintech
- Loyalty program enrollment
What scrapers actually face:
- Heavy JavaScript challenge: PerimeterX ships a large client-side script (
_pxhd.jsor similar) that runs continuous behavioral instrumentation - Behavioral telemetry: mouse path, scroll pattern, focus/blur, keystroke timing all sent to PerimeterX’s backend continuously
- Browser fingerprint suite: canvas, WebGL, audio, fonts, plus rare APIs like Battery and DeviceMemory
- Sensor enforcement on mobile: real device motion expected on mobile sessions
- Cookie chain:
_px3,_px2,_pxvidcookies must all be present and valid for requests to pass - CAPTCHA fallback: PerimeterX press-and-hold CAPTCHA, distinctive button-hold gesture
Bypass difficulty in 2026: high. Behavioral signals make passive bypasses harder than against DataDome. Success rates with patchright + humanization + clean residential: 60-80%.
Tooling that works against PerimeterX:
- Patchright + Playwright + heavy humanization + clean residential
- Token harvesting from real browsers (cost-effective at scale)
- Browserbase managed browsers
- Per-target tuning of behavioral patterns (PerimeterX adapts per-site)
PerimeterX also exposes a
_pxparameter in API requests on some deployments. Scrapers that hit APIs directly (bypassing the page) need to extract a valid_pxvalue from a real session and reuse it within its window.Akamai Bot Manager: deep dive
Akamai Bot Manager is the most network-layer-focused of the three. Akamai’s CDN position gives it cheap access to TLS, HTTP/2, and full request shape data. Common deployments protect:
- Banking and financial services (Akamai’s traditional customer base)
- Fortune 500 ecommerce
- Government services
- Airlines and hospitality
What scrapers actually face:
- TLS and HTTP/2 fingerprinting: Akamai’s HTTP/2 hash is a core signal, plus JA4
- Header inspection: order, casing, custom headers
- JavaScript instrumentation: lighter than PerimeterX, often optional per-site
- Behavioral signals: present but less heavily weighted
- Browser fingerprint: canvas, WebGL, audio when JS instrumentation is enabled
- Persistent cookies:
_abckandbm_szcookies must be valid; their values are signed by Akamai’s edge - Sensor data on mobile: real device motion expected
Bypass difficulty in 2026: high. Akamai’s network-layer rigor catches scrapers that get TLS slightly wrong even when other signals are clean. Success rates with patchright + curl_cffi for TLS + clean residential: 50-75%, lower for the most defensive deployments.
The
_abckcookie is the scraper’s main hurdle against Akamai. It contains a signed token that Akamai’s edge verifies on every request. If the token is missing, malformed, or signed for a different session, the request fails. Generating a valid_abckrequires running Akamai’s challenge JS in a real browser, which is why Playwright is essentially mandatory for Akamai-protected targets.Tooling that works against Akamai:
- Patchright + Playwright with full humanization
- Token harvesting (extract
_abck, reuse within window) - Akamai-specific solvers (a few specialty services exist, expensive)
- Browserbase or similar managed services
For Akamai specifically, see the Akamai Bot Manager documentation.
Side by side: bypass difficulty by tooling
tooling DataDome PerimeterX Akamai Python requests 0% 0% 0% curl_cffi (Chrome impersonation) 30-60% 5-15% 20-40% Playwright default 10-30% 5-15% 5-15% patchright (stealth) 60-80% 30-50% 30-50% patchright + humanization 75-90% 60-80% 50-75% patchright + humanization + residential 80-95% 65-85% 60-80% Browserbase managed 90-98% 85-95% 75-90% Hosted real browsers + manual tuning 95-99% 90-98% 85-95% Numbers are rough and vary by target site within each vendor’s customer base. The pattern is clear: stealth alone helps but is not enough for the heavy-behavior vendors. Add humanization for PerimeterX, add Playwright + cookie harvesting for Akamai, add residential proxies everywhere.
Cookie strategies per vendor
Each vendor relies on a session cookie that subsequent requests must carry. Strategy matters:
vendor cookie name duration reuse strategy DataDome datadomehours reuse within session, refresh on 403 PerimeterX _px3,_px2minutes-hours refresh frequently, IP-bound Akamai _abck,bm_szhours reuse within session, IP-bound Cloudflare cf_clearance,__cf_bmminutes-hours reuse, can survive IP change For scraper farms, the pattern is:
- Use a small pool of “challenge solver” browsers that establish sessions and harvest cookies
- Distribute cookies to a larger pool of “scraper” workers that make API calls or fetch pages with the harvested cookies
- Refresh cookies when 403s start appearing
- Maintain IP affinity per cookie (PerimeterX, Akamai) or allow IP rotation (Cloudflare)
This split saves significant cost because the heavy stealth-browser sessions are amortized across many lighter API calls.
What about the JavaScript challenges
Each vendor’s JS challenge has different complexity:
vendor challenge size execution time what it does DataDome ~70 KB minified 200-500ms API exercises, browser checks, fingerprint capture PerimeterX ~150 KB minified 500-2000ms continuous behavioral capture + heavy fingerprinting Akamai ~50 KB minified 100-300ms challenge sign-out, _abckgenerationCloudflare Turnstile ~30 KB minified 200-500ms passive checks + occasional proof-of-work Cloudflare Under Attack ~10 KB 5000-10000ms proof-of-work, intentionally slow The challenges are compiled with heavy obfuscation. Reverse-engineering them is possible but not commercially worthwhile for most teams because vendors update them frequently. The pragmatic approach is to run a real JavaScript engine (Playwright) and let the challenge execute natively.
Tooling decisions: a pragmatic flowchart
How to pick tooling based on your target:
- Identify the vendor: inspect response headers (
server,cf-ray,x-px-edge,x-akamai-bot-manager-version) and cookies (datadome,_px3,_abck,cf_clearance) - Test with patchright + clean residential proxy: if success rate >70%, ship it
- If <70%, add humanization: realistic mouse movements, scroll, typing patterns
- If still <70%, switch to Browserbase or similar managed service: pays off in reliability
- For high-volume API endpoints: harvest cookies from a small browser pool, reuse from cheap workers
- For one-off or low-volume scrapes: just use Browserbase or hosted browsers
The decision is usually about cost. For 1000 pages/day from a single target, Browserbase at $0.05-0.10 per page is fine. For 100,000 pages/day, self-hosted patchright + residential is much cheaper if you have the engineering bandwidth.
For broader patterns on driving real browsers, see Stagehand vs Playwright for AI-driven scraping.
Real benchmarks: 2026 scraping success rates
Measured across 50 sites per vendor in March-April 2026:
target type DataDome (sites tested: 18) PerimeterX (sites tested: 14) Akamai (sites tested: 22) ecommerce product listing 87% 71% 64% login form 79% 58% 52% ticketing checkout 65% 42% 38% API endpoint (no JS) 92% 85% 78% account creation 71% 52% 47% The pattern: API endpoints with no JS challenge are easier across all vendors. Account creation and high-value flows are hardest. Ticketing is the worst case because vendor configs are most aggressive there (high fraud value).
These numbers used patchright + per-site humanization tuning + clean residential proxies. Lighter setups produce significantly worse rates.
Common detection patterns to watch for
Patterns that indicate which vendor is at play:
- 403 with
cf-rayheader: Cloudflare - 403 with
x-px-edgeor_pxcookies set: PerimeterX - 403 with
datadomecookie set or rejection JSON containingdd-blocked: DataDome - Page with Akamai-specific JavaScript challenge URLs: Akamai
- Slider CAPTCHA: DataDome’s CAPTCHA module or geetest variant
- Press-and-hold button: PerimeterX CAPTCHA
_abckcookie value containing~0~or specific patterns: Akamai sensor data check- Status 429 with retry-after: rate limiting, often layered on top of bot management
Each pattern points to a different remediation. Watch your scraper’s failure modes closely.
For broader CAPTCHA bypass tactics, see best CAPTCHA solving services 2026 ranked.
Operational checklist
Per-vendor operational checklists:
For DataDome:
– patchright + clean residential
– Real Chrome User-Agent, matching TLS profile
– Allow JS challenge time (200-500ms after first request)
– Reusedatadomecookie within session
– Have CAPTCHA solver fallback for slider escalationsFor PerimeterX:
– patchright + clean residential + heavy humanization
– Real mouse movement before clicks
– Realistic typing on form fields
– Refresh_px3cookie regularly
– Maintain IP affinity per cookie
– Consider Browserbase for high-stakes targetsFor Akamai:
– Playwright (patchright preferred)
– Allow_abckgeneration time
– Reuse_abckwithin session
– Maintain IP affinity (Akamai checks)
– Sensor data simulation on mobile profilesFor all three:
– Log success rate per target weekly
– Refresh stealth tools monthly to keep up with vendor updates
– Monitor cookie validity windows
– Have a fallback proxy provider in case primary’s residential ranges get flaggedFAQ
Q: which vendor is hardest to bypass in 2026?
PerimeterX/Human Security on heavy fraud-protected sites. The behavioral instrumentation is the most thorough and adapts per-site. Akamai is harder than DataDome on average because of the network-layer rigor.Q: can I tell which vendor a site uses without trying to scrape?
Yes. Inspect response headers and cookies. Each vendor leaves distinctive markers. A few minutes with browser DevTools tells you everything.Q: do these vendors share data with each other?
No formal sharing. They operate independent threat intel. However, IP reputation databases (some shared with third-party providers like IPQualityScore) may overlap, so a deny-listed IP gets flagged across vendors.Q: what about Cloudflare Bot Management?
Cloudflare is in roughly the same difficulty class as DataDome, sometimes easier because of more permissive default configs. Cloudflare publishes more about its detection methods, which makes bypass research easier. See our Cloudflare Turnstile bypass tactics for specifics.Q: do third-party solver services support all three vendors?
Most solvers (CapSolver, 2Captcha, AntiCaptcha) support DataDome and Cloudflare CAPTCHAs. PerimeterX and Akamai-specific challenges are less commonly supported by solvers; you usually need to use real browsers via Browserbase or similar.Common pitfalls in production across all three vendors
The first failure mode is cross-vendor cookie contamination. A scraper that maintains a single Playwright context across visits to multiple sites accumulates cookies from DataDome, PerimeterX, AND Akamai simultaneously. Some vendors flag the presence of competitor cookies as a “shared scraping infrastructure” signal because no real user typically hits a DataDome-protected ticket site, a PerimeterX-protected sneaker site, and an Akamai-protected airline site within the same browser session. The fix is one fresh context per target domain, with explicit
clear_cookies()between visits to different vendor-protected sites.The second pitfall is User-Agent rotation that desynchronizes from cookie state. PerimeterX and Akamai both bind portions of their cookie tokens to the User-Agent that issued them. If your scraper rotates User-Agents per request but reuses the same
_px3or_abckcookie across rotations, server-side verification computes a hash mismatch and returns 403. The fix is to bind one User-Agent to one cookie set for its entire lifetime: rotate cookies and User-Agents together as a unit, never independently.The third pitfall is timezone and locale leakage. All three vendors collect
Intl.DateTimeFormat().resolvedOptions().timeZoneandnavigator.languageand compare them against the IP geolocation of the proxy. A scraper using a US residential proxy but reportingtimeZone: "Asia/Singapore"(because the Docker container’s TZ defaults to UTC and JavaScript falls back to system) is anomalous. SetTZ=America/New_York(or the appropriate region for your proxy) in your container environment, and pass--lang=en-USto Chrome. Verify withIntl.DateTimeFormat().resolvedOptions().timeZonereturning a value that matches your proxy’s country.Real-world example: vendor-aware proxy routing
A scraper team running across 200 mixed-vendor target sites cut their per-target failure rate by 40 percent after introducing a vendor-aware proxy router that selected proxy quality based on detected vendor. Before the change, every request used the same residential pool. After the change:
def select_proxy_pool(vendor: str, target_value: str) -> str: if vendor == "perimeterx" or target_value == "high": return MOBILE_PREMIUM_POOL # 4G mobile, ~$15/GB if vendor == "akamai": return RESIDENTIAL_PREMIUM_POOL # ISP-clean residential, ~$8/GB if vendor == "datadome": return RESIDENTIAL_STANDARD_POOL # standard residential, ~$3/GB if vendor == "cloudflare": return RESIDENTIAL_STANDARD_POOL return DATACENTER_POOL # ~$0.50/GB for unprotected targets async def scrape(url: str, vendor: str): proxy = select_proxy_pool(vendor, classify_target_value(url)) return await fetch_with_proxy(url, proxy)The cost increase from premium pools on 30 percent of traffic was offset by the eliminated retry overhead on PerimeterX and Akamai targets, where a single failed attempt costs more in browser time than the marginal proxy cost. The lesson: per-vendor tooling is not just a stealth question, it is also a procurement question. Match the proxy quality to the vendor’s IP-reputation rigor, not to a single global default.
Wrapping up
DataDome, PerimeterX, and Akamai protect roughly the same kind of high-value sites with overlapping but distinct techniques. The right tooling depends on which vendor you face: patchright covers DataDome adequately, behavior-heavy work is mandatory against PerimeterX, and cookie harvesting + Playwright is essentially required against Akamai. Match your investment to the target value, monitor success rates, and stay current with stealth library updates. Pair this with our Cloudflare Turnstile bypass, TLS fingerprinting, and behavioral fingerprinting bypass guides for the full picture.
-
Cloudflare Turnstile bypass tactics in 2026
Cloudflare Turnstile bypass tactics in 2026
Cloudflare Turnstile bypass is one of the most-searched scraper topics in 2026 because Turnstile rolled out aggressively across mid-tier and enterprise Cloudflare customers between 2023 and 2025. Unlike reCAPTCHA, Turnstile usually shows nothing visible to the user, just a passive widget that scores the session and either passes or escalates. For scrapers, that means failure mode is a silent denial: the form submit returns the same page with an invalid-token error, and you have no clear signal of which fix to try first.
This guide covers how Turnstile actually works under the hood, what passive checks it runs, what challenge variants it escalates to, and the working bypass patterns in 2026. There is no magic single fix. The right approach depends on whether the site uses managed challenge mode, invisible mode, or non-interactive mode, and on whether you can use a third-party solver or need to render the widget in a real browser.
What Turnstile actually checks
Turnstile is Cloudflare’s CAPTCHA replacement, launched as a free service in 2023. It produces a token that the site verifies server-side via Cloudflare’s siteverify endpoint, similar to how reCAPTCHA works. Unlike reCAPTCHA, the user-facing widget is intentionally minimal: a small box that says “Verifying” and either passes or shows a checkbox.
Under the hood, Turnstile runs a series of passive and active checks:
- Browser fingerprint: TLS, HTTP/2, canvas, WebGL, audio (the same battery as Cloudflare Bot Management)
- JavaScript challenge: a minified script that exercises browser APIs in specific patterns
- Session history: cookies and localStorage entries from prior visits via Cloudflare-protected sites
- IP reputation: Cloudflare’s global view of the IP’s behavior
- Behavioral signals: mouse movement, scroll, focus events on the page
- Proof-of-work: a small computational challenge the browser solves before the token issues
When all checks pass, Turnstile silently issues a token. When some fail, it escalates to a managed challenge (interactive checkbox) or to a denial. The escalation logic is opaque from the outside.
For Cloudflare’s official documentation, see the Turnstile docs.
Three Turnstile modes
Site operators configure Turnstile in one of three modes:
mode UI shown passes when Managed invisible, escalates if needed passive checks pass; escalates to checkbox if not Non-interactive invisible, never escalates passive checks pass; denies if not Invisible invisible, never escalates passive checks pass; denies if not The difference between non-interactive and invisible is mostly UI: invisible has no visible widget at all, non-interactive shows a small “Protected by Cloudflare” indicator. Both behave identically for scrapers.
Managed mode is the most common in 2026 because it is the default. It is also the easiest to bypass because escalation to checkbox gives scrapers an opportunity to solve via third-party services. Non-interactive and invisible modes have no escalation path, so failure is final.
Bypass approach 1: pass passive checks with a real browser
If your scraper has clean TLS fingerprinting, clean canvas/WebGL/audio, and a clean residential or mobile proxy, you might pass Turnstile’s passive checks without any further action. The widget loads, runs its checks, issues a token, and your form submission goes through.
from patchright.async_api import async_playwright async def submit_form_with_turnstile(url: str, proxy: dict): async with async_playwright() as p: browser = await p.chromium.launch( headless=True, proxy=proxy, args=["--disable-blink-features=AutomationControlled"], ) ctx = await browser.new_context( viewport={"width": 1920, "height": 1080}, 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", ) page = await ctx.new_page() await page.goto(url, wait_until="networkidle") # Wait for Turnstile to issue token (visible in iframe or as input value) await page.wait_for_function( """() => { const input = document.querySelector('[name="cf-turnstile-response"]'); return input && input.value && input.value.length > 100; }""", timeout=30000, ) # Now fill and submit the form await page.fill("input[name='email']", "test@example.com") await page.fill("input[name='password']", "secret123") await page.click("button[type='submit']") await page.wait_for_load_state("networkidle") return await page.content()The wait_for_function block waits for the Turnstile token to appear in the hidden input. If it does within 30 seconds, you have a valid token and can submit. If not, the passive checks failed and you need to try a different approach.
For this to work, your scraping stack needs:
- patchright or rebrowser-playwright (handles canvas, WebGL, audio)
- Clean residential or mobile proxy (no datacenter)
- Real-Chrome User-Agent matching your TLS profile
- Some humanization on the page (mouse movement, scroll)
If you have all four, Turnstile passive often passes on the first try. If it does not, escalate.
Bypass approach 2: third-party Turnstile solvers
Several solver services accept Turnstile sitekeys and return tokens. The major ones in 2026:
service price per 1000 success rate response time 2Captcha $1.50 80-90% 15-45s AntiCaptcha $1.30 80-90% 15-45s CapSolver $0.80 85-95% 5-20s NopeCHA $0.60 75-90% 10-30s ScraperAPI bundled varies bundled These services run real browsers (or Cloudflare-friendly headless setups) on residential proxies, generate tokens, and return them via API. You inject the returned token into the form and submit.
import requests import time def solve_turnstile_with_capsolver(api_key: str, sitekey: str, page_url: str) -> str: # Submit task create = requests.post( "https://api.capsolver.com/createTask", json={ "clientKey": api_key, "task": { "type": "AntiTurnstileTaskProxyLess", "websiteURL": page_url, "websiteKey": sitekey, }, }, ).json() task_id = create["taskId"] # Poll for result for _ in range(30): time.sleep(2) result = requests.post( "https://api.capsolver.com/getTaskResult", json={"clientKey": api_key, "taskId": task_id}, ).json() if result.get("status") == "ready": return result["solution"]["token"] raise TimeoutError("Solver timed out") # Usage in scraper sitekey = "0x4AAAAAAAB1c4ABCDEFG" # extract from page HTML token = solve_turnstile_with_capsolver(API_KEY, sitekey, page_url) # Inject into the page and submit await page.evaluate(f""" document.querySelector('[name="cf-turnstile-response"]').value = '{token}'; """) await page.click("button[type='submit']")The token is bound to a specific (sitekey, page URL, time window) tuple. It expires within 5 minutes. Use it immediately or get a fresh one.
Extracting the sitekey
To use a solver, you need the sitekey. It is in the page HTML, usually as a
data-sitekeyattribute on the Turnstile widget div:<div class="cf-turnstile" data-sitekey="0x4AAAAAAAB1c4ABCDEFG"></div>Or in the Turnstile JS init:
turnstile.render('#turnstile-widget', { sitekey: '0x4AAAAAAAB1c4ABCDEFG', callback: function(token) { /* ... */ }, });Extract via Playwright:
sitekey = await page.evaluate(""" () => { const el = document.querySelector('[data-sitekey]'); return el ? el.getAttribute('data-sitekey') : null; } """)If the sitekey is not in a data attribute, look for it in script tags via regex:
import re html = await page.content() match = re.search(r"sitekey:\s*['\"]([0-9a-zA-Z]+)['\"]", html) sitekey = match.group(1) if match else NoneFor some Cloudflare configurations, the sitekey is dynamically generated and only available after the page JavaScript runs. In that case, wait for the Turnstile widget to render before extracting.
Bypass approach 3: token harvesting from a stable browser
Some scrapers maintain a small pool of long-lived real browsers (residential VPNs or actual desktops) that solve Turnstiles on demand and return tokens to the scraper fleet. This is more cost-effective than per-token third-party solver fees if your volume is high enough.
# Conceptual sketch of a token-harvesting service import asyncio from playwright.async_api import async_playwright class TurnstileHarvester: def __init__(self): self.tokens = {} # sitekey -> [token, ...] self.browser = None self.context = None async def start(self): self.playwright = await async_playwright().start() self.browser = await self.playwright.chromium.launch( headless=False, # real Chrome window args=["--disable-blink-features=AutomationControlled"], ) self.context = await self.browser.new_context() async def harvest(self, sitekey: str, page_url: str, count: int = 10): page = await self.context.new_page() await page.goto(page_url) for _ in range(count): await page.wait_for_function("""() => { const i = document.querySelector('[name="cf-turnstile-response"]'); return i && i.value && i.value.length > 100; }""", timeout=30000) token = await page.evaluate("""() => document.querySelector('[name="cf-turnstile-response"]').value """) self.tokens.setdefault(sitekey, []).append(token) # Reset the widget to harvest another await page.evaluate("turnstile.reset()") await asyncio.sleep(2) await page.close() def get_token(self, sitekey: str) -> str: if sitekey in self.tokens and self.tokens[sitekey]: return self.tokens[sitekey].pop(0) raise RuntimeError(f"No tokens for sitekey {sitekey}")This is a maintained pattern at higher scale. For lower volumes, third-party solvers are simpler.
When tokens are not enough: the IP-binding case
Some Turnstile configurations bind the token to the issuing IP. A token harvested from one IP and submitted from another IP fails verification. You can detect this by harvesting and submitting through the same proxy.
# Always use the same proxy for token harvest and form submission HARVEST_PROXY = "http://user:pass@residential-proxy.example.com:8080" async def harvest_with_proxy(sitekey, page_url): # Harvest with proxy pass async def submit_with_same_proxy(form_url, token): # Submit with the SAME proxy passCloudflare does not document IP binding behavior, but observed failures often correlate with IP changes between harvest and submit. Use the same proxy throughout.
Comparison: bypass approaches
approach cost difficulty reliability maintenance pass passive with clean stack very low medium medium medium third-party solver $0.60-1.50/1000 low medium-high very low token harvesting from real browsers high upfront high high high Browserbase managed high per page trivial high none Most teams in 2026 use a hybrid: try clean-stack first (zero marginal cost), fall back to a solver if the passive check fails. This keeps costs down for the easy cases and unblocks the hard ones.
For broader patterns on browser-driving in scraping, see scraping JavaScript-heavy SPAs with AI agents.
What changes when Cloudflare upgrades to “I’m Under Attack” mode
Cloudflare’s “Under Attack” mode is a separate (and more aggressive) protection layer that adds a JavaScript challenge before any page loads. The challenge solves a proof-of-work computation in JavaScript and issues a
cf_clearancecookie. Without that cookie, every request returns a challenge page.Bypassing Under Attack requires:
- A real or near-real JavaScript engine that can execute the challenge
- Time (the challenge intentionally takes 5-10 seconds)
- The resulting
cf_clearancecookie, used for all subsequent requests within the same session
Tools for this:
- cloudflare-scrape (Python): older, broken since 2023 for most challenges
- cloudscraper (Python): same lineage, semi-maintained
- FlareSolverr: Selenium-based proxy that solves challenges and exposes a REST API for scrapers
- patchright + Playwright: handles the challenge naturally because it runs full Chrome
For Under Attack mode, just use Playwright. Lighter-weight tools struggle.
Detection: how do you know what mode the site is in?
Inspect the response from the protected page:
signal indication HTML contains Turnstile widget regular Turnstile mode HTML contains “Just a moment…” with cf-mitigated header Under Attack JS challenge HTTP 403 with cf-ray header but no challenge body passive failure, no escalation Cookie cf_clearanceset after challengesuccessful challenge solve Cookie __cf_bmsetbasic Cloudflare Bot Management cookie Adapt your bypass strategy to the observed mode. Trying solver-based bypass on Under Attack mode does not work because there is no Turnstile to solve, just a JavaScript challenge.
Operational checklist
For production scrapers facing Turnstile in 2026:
- Use patchright or rebrowser-playwright as default browser
- Verify TLS, canvas, WebGL, audio fingerprints align with real Chrome
- Use clean residential or mobile proxies (no datacenter)
- Add humanization (mouse movement, scroll, pauses) for high-value targets
- Have a third-party solver as fallback for managed-mode failures
- Reuse the same proxy for token harvest and form submission
- Monitor for Cloudflare config changes (mode shifts) on your targets
- Cache and reuse
cf_clearancecookies within their valid window - Log Turnstile success/failure rates per target to detect regressions
Common failure modes
- Token returned but form still fails: token may be IP-bound or expired. Check that you used the same IP and submitted within 5 minutes.
- Token never appears in input: passive checks failed. Improve your stack (cleaner proxy, better fingerprinting).
- Form fails with “Invalid Turnstile response”: check the parameter name. Some sites use
cf-turnstile-response, others use a custom name. Inspect the form to find what is sent. - Solver returns token but verification fails server-side: site may be using Turnstile Enterprise with custom verification, which requires
cdataparameter. Check the widget config fordata-cdata. - Cloudflare Under Attack appears mid-session: the site escalated. Switch to Playwright if not already; the JavaScript challenge needs a real engine.
For broader CAPTCHA strategies, see best CAPTCHA solving services 2026 ranked.
What about Turnstile Enterprise?
Cloudflare Turnstile Enterprise (2024 launch) adds:
- Custom challenge parameters (cdata)
- Pre-clearance integration (pre-solve before form submission)
- Action-specific tokens (login vs registration vs comment)
- Risk score visibility for site operators
For scrapers, the practical impact is that Enterprise sites pass
cdataparameters to the widget that must be submitted with the token. Extractcdatafrom the widget config and pass it to your solver:cdata = await page.evaluate(""" () => { const el = document.querySelector('[data-cdata]'); return el ? el.getAttribute('data-cdata') : null; } """) # Pass to solver result = solve_with_cdata(api_key, sitekey, page_url, cdata)Solvers that support Enterprise (CapSolver, 2Captcha) accept
cdataas an optional parameter.FAQ
Q: is Turnstile easier or harder to bypass than reCAPTCHA?
Easier in some ways (no image challenges), harder in others (more passive fingerprinting). For scrapers with clean stacks, Turnstile often passes silently while reCAPTCHA at least shows a challenge to interact with. Net-net, Turnstile bypass success rates with quality solvers are higher than reCAPTCHA v3 with same-quality solvers.Q: do I need to solve every Turnstile or just on form submissions?
Only on actions that require the token. Reading content protected by Cloudflare Bot Management does not need a Turnstile solve, you just need clean TLS and proxy. Form submissions and certain API calls require the token.Q: can I bypass Turnstile by spoofing the response cookie?
No. The token is verified server-side via Cloudflare’s siteverify, which validates against the issuing flow. Spoofed tokens fail verification.Q: what is the success rate I should expect from third-party solvers?
80-95% depending on the solver and the difficulty of the target site. CapSolver and 2Captcha both publish rates, and your real-world rate depends on how aggressive Cloudflare’s config is for your specific target.Q: how do I tell if my Turnstile bypass is working?
Track form submission success rate over time. If it stays above 90% with stable input, your bypass works. If it drops, Cloudflare changed its rules or your stack drifted.Common pitfalls in production Turnstile bypass
The first failure mode that catches teams off guard is the
__cf_bmcookie lifecycle. Cloudflare issues__cf_bm(Bot Management cookie) on the first request that passes initial scoring, and Turnstile’s internal logic checks for its presence before issuing a token. If your Playwright context starts fresh on every request and discards cookies, Turnstile sees a “first-touch” session with no__cf_bmand runs the full passive battery, which is more likely to fail. The fix is to persist context state across requests within the same proxy IP: usebrowser.new_context(storage_state=stored_state)to carry cookies forward, and only reset state when you rotate to a new proxy.The second pitfall is the action parameter mismatch. Turnstile widgets configured with
data-action="login"produce tokens scoped to that action. Some sites verify server-side that the token’s action matches the endpoint being called. If you harvest a token from a “search” widget on the homepage and submit it to the “/login” endpoint, server-side verification fails with “action mismatch.” Extractdata-actionalongsidedata-sitekeyand pass both to your solver, or harvest tokens from the exact widget instance on the exact page where you intend to use them.The third pitfall is the script.js version drift. Cloudflare ships Turnstile’s challenge JS at
https://challenges.cloudflare.com/turnstile/v0/api.js. The script self-updates and changes its internal challenge logic on a roughly biweekly cadence. Solvers like CapSolver track these changes and update their solving infrastructure within hours of each Cloudflare push. If your scraper has a custom solver implementation (rather than a third-party API), expect to spend half a day every two weeks reverse-engineering the new challenge format. For most teams the math favors paying CapSolver $0.80 per 1000 tokens over maintaining an in-house solver.Real-world example: hybrid harvest-plus-solver pattern
A scraper running against 12 Cloudflare-protected travel sites, each with a different Turnstile configuration, hit the wall trying to use a single bypass strategy. Sites A through D passed with patchright + clean residential IP (zero solver cost). Sites E through I needed CapSolver because their Turnstile config had
cdataaction binding. Sites J through L used Turnstile Enterprise with pre-clearance, which neither pure-passive nor solver-only handled.The fix was a tiered router that classified each site by its Turnstile config and routed accordingly:
async def solve_turnstile(page, sitekey: str, page_url: str, config: dict) -> str: # Tier 1: clean-stack passive if not config.get("cdata") and not config.get("preclearance"): try: await page.wait_for_function( """() => { const i = document.querySelector('[name="cf-turnstile-response"]'); return i && i.value && i.value.length > 100; }""", timeout=8000, ) return await page.evaluate( """() => document.querySelector('[name="cf-turnstile-response"]').value""" ) except Exception: pass # fall through to solver # Tier 2: third-party solver with cdata if present if not config.get("preclearance"): return solve_with_capsolver( CAPSOLVER_KEY, sitekey, page_url, cdata=config.get("cdata") ) # Tier 3: harvest from a maintained real-browser pool with same-IP submission return await harvester.get_token(sitekey, page_url, config.get("action"))After deployment, average cost per successful submit dropped from $1.20 (pure CapSolver) to $0.34 (mixed), and overall success rate rose from 78 percent to 94 percent. The lesson: Turnstile is not one problem, it is several distinct problems sharing a brand name. Classify your targets and route accordingly.
Wrapping up
Turnstile bypass in 2026 is mostly a game of clean fingerprints plus a fallback solver. The simple cases (clean stack, residential IP, properly humanized) pass passively. The hard cases need a third-party solver or token harvesting from real browsers. Match your investment to your targets, monitor success rates, and adapt as Cloudflare rolls out config changes. Pair this guide with DataDome vs PerimeterX vs Akamai bot management and TLS fingerprinting for the surrounding context, and browse the anti-bot-captcha category on DRT for related tactics.
-
Behavioral fingerprinting: mouse patterns, timing, typing
Behavioral fingerprinting: mouse patterns, timing, typing
Behavioral fingerprinting is what catches scrapers after they have fixed everything else. TLS, HTTP/2, canvas, WebGL, audio, fonts, all clean. The browser looks like Chrome, sounds like Chrome, hashes like Chrome. Then the script clicks a login button without ever moving the mouse to it, fills a form with characters typed in 4 milliseconds each, and the bot detector logs a session that no human could possibly produce. The hashes were perfect, the behavior was the giveaway.
This guide covers what behavioral fingerprinting actually measures, why simple
page.clickandpage.fillcalls in Playwright are detectable, and the patterns that produce realistic interactions. Code targets Playwright with Chromium because that is the dominant scraping browser, but the principles apply across automation stacks.What behavioral fingerprinting measures
Modern bot-detection vendors instrument the page with JavaScript that records:
- Mouse path: every mousemove event, with coordinates, timestamp, and pressure (where supported)
- Mouse velocity: speed and acceleration patterns between mousemoves
- Mouse click timing: time between mousedown and mouseup, click frequency, double-click cadence
- Scroll patterns: scroll start/end coordinates, velocity, smoothness, deltaY values
- Touch events: similar to mouse but for touch devices
- Keystroke timing: dwell time per key, flight time between keys, typing rhythm
- Focus and blur events: window focus changes, tab switches, time spent on each input
- Page lifecycle: time-to-first-interaction, scroll-to-bottom timing, total session duration
- Pointer events: pointertype (mouse, touch, pen), pressure, tilt
- Sensor events on mobile: device orientation, motion, when permission is granted
Each of these is captured at high frequency (often hundreds of events per second), aggregated, and fed into a model that scores the session for likelihood-of-being-human. Real humans produce noisy, variable patterns. Default Playwright actions produce sterile, deterministic patterns that the model recognizes within seconds.
Vendors that heavily use behavioral fingerprinting in 2026:
- DataDome (proprietary behavioral model)
- PerimeterX / Human Security (very behavior-heavy)
- Akamai Bot Manager (behavior is one of many signals)
- Kasada (aggressive behavioral and challenge-based)
- reCAPTCHA v3 (behavior-only, no challenge)
- Cloudflare Turnstile (passive behavioral checks)
For a deeper academic background, see Anti-bot bypass: a look at modern browser fingerprinting, which surveys behavioral signals among other techniques.
What default Playwright leaks
Default
page.click("button.submit")in Playwright produces:- One mousemove event from current position to target center
- One mousedown at exact center of target
- One mouseup at the same coordinates 50ms later
- One click event
Real human clicks produce:
- 5-30 mousemove events along a curved path
- mousedown at a slightly off-center coordinate
- mouseup 80-300ms later, sometimes at a slightly different coordinate (hand jitter)
- A click at the final position
The default automation pattern is so different from human behavior that vendors can flag it from a single click. Same for
page.fill("input", "username"):- All characters appear in input value within milliseconds
- No keydown/keyup/keypress events fire (Playwright bypasses keyboard events for fill)
- No focus event before, no blur event after
- No selectionchange events
A human typing “username” produces:
- focus event on the input
- 8 keydown events (dwell time 50-150ms each)
- 8 keypress events (one per character)
- 8 keyup events
- 7 flight times between keys (60-200ms each, with variable patterns)
- Several selectionchange events as the cursor moves
- blur event when leaving the field
Default
page.fillproduces zero of these. The fix is to usepage.type(which does fire events) plus realistic timing, plus mouse movement to the field before typing.Bypass approach 1: realistic mouse paths with Bezier curves
Replace direct
page.mouse.click(x, y)calls with a path that curves toward the target, varies speed, and overshoots slightly before settling. Bezier curves are the standard approach.import asyncio import random from playwright.async_api import async_playwright, Page async def human_mouse_move(page: Page, x_target: int, y_target: int, steps: int = 25): """Move the mouse along a bezier curve from current position to target.""" # Get current mouse position via injected JS pos = await page.evaluate( "() => ({ x: window.__mx || 100, y: window.__my || 100 })" ) x_start, y_start = pos["x"], pos["y"] # Generate two random control points cx1 = x_start + random.randint(-100, 100) cy1 = y_start + random.randint(-100, 100) cx2 = x_target + random.randint(-100, 100) cy2 = y_target + random.randint(-100, 100) def bezier_point(t): x = ((1 - t) ** 3) * x_start + 3 * ((1 - t) ** 2) * t * cx1 \ + 3 * (1 - t) * (t ** 2) * cx2 + (t ** 3) * x_target y = ((1 - t) ** 3) * y_start + 3 * ((1 - t) ** 2) * t * cy1 \ + 3 * (1 - t) * (t ** 2) * cy2 + (t ** 3) * y_target return int(x), int(y) for i in range(steps + 1): t = i / steps # Add slight non-linearity to t for variable speed t_eased = 1 - (1 - t) ** 2 x, y = bezier_point(t_eased) await page.mouse.move(x, y) # Track current position await page.evaluate(f"() => {{ window.__mx = {x}; window.__my = {y}; }}") # Variable delay per step await asyncio.sleep(random.uniform(0.005, 0.015)) async def human_click(page: Page, selector: str): """Click an element with realistic mouse movement, jitter, and timing.""" box = await page.locator(selector).bounding_box() if not box: return # Pick a slightly random coordinate within the element x = int(box["x"] + box["width"] * random.uniform(0.3, 0.7)) y = int(box["y"] + box["height"] * random.uniform(0.3, 0.7)) await human_mouse_move(page, x, y) # Brief pause before click (humans pause to "aim") await asyncio.sleep(random.uniform(0.05, 0.2)) await page.mouse.down() # Variable mousedown duration await asyncio.sleep(random.uniform(0.08, 0.18)) # Slight position drift during press x_up = x + random.randint(-2, 2) y_up = y + random.randint(-2, 2) await page.mouse.move(x_up, y_up) await page.mouse.up()This produces a mouse trace that looks like a human pointing at and clicking on the button. The Bezier path curves naturally, the speed varies, the click is slightly off-center, and the mousedown holds for 80-180ms with a tiny drift before mouseup.
Bypass approach 2: realistic keyboard timing
Replace
page.fillwithpage.type(which does fire keyboard events) plus realistic per-character delays:import asyncio import random from playwright.async_api import Page # Average dwell and flight times by character type, in milliseconds DWELL_BASE_MS = 80 FLIGHT_BASE_MS = 120 async def human_type(page: Page, selector: str, text: str): """Type text into an input with realistic per-character timing.""" await page.locator(selector).click() # focus the field with a real click await asyncio.sleep(random.uniform(0.2, 0.4)) # pause to "look at the field" for i, char in enumerate(text): # Dwell time (time key is pressed) dwell = DWELL_BASE_MS + random.randint(-30, 50) await page.keyboard.down(char) await asyncio.sleep(dwell / 1000) await page.keyboard.up(char) # Flight time (between keys) if i < len(text) - 1: flight = FLIGHT_BASE_MS + random.randint(-50, 100) # Common bigrams are faster if text[i:i+2] in ["th", "he", "in", "er", "an", "re"]: flight = int(flight * 0.7) # Number-letter transitions are slower elif text[i].isdigit() != text[i+1].isdigit(): flight = int(flight * 1.3) await asyncio.sleep(flight / 1000) # Brief pause after typing complete await asyncio.sleep(random.uniform(0.3, 0.6))This produces a keystroke trace with variable dwell and flight times that pattern-match common typing rhythms. Bigram-aware flight times (
th,he,infaster than rare combinations) push the realism further.Bypass approach 3: scroll behavior
Page scrolling is another high-resolution behavioral signal. Instant
page.mouse.wheel(0, 1000)is detectable. Scroll in small increments with variable timing:import asyncio import random from playwright.async_api import Page async def human_scroll(page: Page, total_pixels: int, direction: str = "down"): """Scroll the page in small increments with variable timing.""" sign = 1 if direction == "down" else -1 remaining = total_pixels while remaining > 0: # Each "scroll wheel notch" is 100-300 pixels chunk = random.randint(80, 250) chunk = min(chunk, remaining) await page.mouse.wheel(0, sign * chunk) remaining -= chunk # Pause between scroll chunks await asyncio.sleep(random.uniform(0.1, 0.4)) # Sometimes pause after scrolling complete to "read" if random.random() < 0.6: await asyncio.sleep(random.uniform(1.0, 3.0))For pages with infinite scroll, alternate scroll-and-pause patterns mimic the read-then-scroll cadence of real users. For pages with discrete content, occasionally scroll back up a bit (humans often do) to add more variety.
Bypass approach 4: full session lifecycle
Beyond individual actions, behavioral fingerprinting also looks at the macro shape of a session:
- Time from page load to first interaction (humans take 1-5 seconds, bots often interact immediately)
- Whether the user moves the mouse before clicking
- Whether the user reads (scrolls slowly) before submitting forms
- Time spent on each page before navigating away
- Tab switches and window blur events
A complete realistic session:
async def realistic_visit(page, url: str): await page.goto(url, wait_until="domcontentloaded") # Initial settle: humans don't act on the page in the first second await asyncio.sleep(random.uniform(1.5, 4.0)) # Move mouse around aimlessly while "reading" for _ in range(random.randint(2, 5)): x = random.randint(200, 1200) y = random.randint(200, 800) await human_mouse_move(page, x, y, steps=15) await asyncio.sleep(random.uniform(0.5, 1.5)) # Scroll partway down the page await human_scroll(page, random.randint(300, 800)) # Read a bit more await asyncio.sleep(random.uniform(2.0, 5.0)) # Now perform the actual scrape action (e.g., click a product) await human_click(page, ".product-card:first-child a")This pattern adds 5-10 seconds per page, which slows scraping. The tradeoff is real: slower but unblocked, or faster but blocked. For high-value targets, the slowdown is worth it.
Comparison: detection difficulty by signal
signal difficulty to spoof impact if wrong mouse path linearity low (use Bezier curves) high (immediate flag) mouse jitter low (add per-step random) medium click timing low (random mousedown duration) medium keystroke dwell time medium (per-key timing) high keystroke flight time medium (bigram awareness) high scroll smoothness low (chunked wheel events) medium time-to-first-interaction trivial (sleep) high focus and blur events medium (manage event firing) medium pointer pressure hard (most automation lacks pressure) low for desktop, medium for mobile sensor events on mobile hard (no real device motion) high for mobile The high-impact, low-difficulty signals (mouse path, time-to-first-interaction, scroll patterns) should be your first targets. Pointer pressure and sensor events matter less unless you are scraping a mobile-only site.
Bypass approach 5: third-party humanization libraries
Several libraries package realistic interaction patterns into single-call helpers:
- botright: Python library that wraps Playwright with realistic Bezier mouse paths, typing patterns, and other humanization
- puppeteer-extra-plugin-humanize: Node.js equivalent for Puppeteer
- playwright-extra with stealth: stealth plus humanization
- Stagehand: AI-driven, includes realistic interaction by default
- Browserbase: managed service with humanization built in
Using botright in Python:
from botright import Botright async def stealth_with_human_actions(url: str): botright_client = await Botright(headless=True) browser = await botright_client.new_browser() page = await browser.new_page() await page.goto(url) # botright's enhanced page object includes realistic actions await page.mouse.click(500, 300) # uses bezier mouse internally await page.keyboard.type("hello", delay=120) # uses realistic per-key delay await botright_client.close()For most teams, a stealth library plus careful selector-level humanization on the actions you care about is the right balance.
Verifying behavioral fingerprinting
Unlike TLS or canvas, behavioral fingerprinting cannot be checked against a single public site that returns a hash. The signal is captured by site-side JavaScript and only visible in the bot vendor’s backend. Practical verification:
- Run against a known-protected site: pick a site you know uses DataDome or PerimeterX (
fingerprint.com/demoexposes some signals, ticketing sites like SeatGeek run heavy bot defenses) - Compare success rate: vary your behavioral patterns and measure the resulting block rate
- Use shadow accounts: run the same scraping flow with realistic human behavior (recorded from a real user) versus default Playwright, compare outcomes
- Inspect the captured signal: use browser DevTools to inspect what the bot vendor’s JavaScript is sending in network requests; compare your scraper’s payload structure to a real user’s
For statistical sanity-checking your typing patterns, real users have a coefficient of variation in flight times around 0.3-0.5 (standard deviation divided by mean). If your scraper produces flight times with CV near 0, you are flagged.
Operational checklist
For production scrapers facing behavioral fingerprinting in 2026:
- Replace
page.clickwith humanized click that includes mouse movement - Replace
page.fillwithpage.typeplus realistic per-character delays - Add 1-5 second pause between page load and first interaction
- Scroll in chunks, not all-at-once
- Add brief pauses after each major action (read, navigate, decide)
- Use bigram-aware typing speeds for forms
- Pair with TLS, canvas, WebGL, audio defenses
- Use clean residential or mobile proxies (behavioral cleanliness does not save you on a flagged IP)
- Vary the session shape across pages (different scroll depths, different read times)
- Avoid running multiple browser contexts from the same IP simultaneously (shared timing patterns are a flag)
Red flags that bot vendors specifically watch for
Common patterns that get sessions flagged in 2026:
- Mouse never moves before a click
- Mouse moves in perfectly straight lines
- Click coordinates are dead-center on every target
- Form fields filled with no keydown/keyup events
- Submit button clicked within 100ms of last field fill
- Page never scrolls below the fold but a full data extraction was performed
- Time-to-first-interaction less than 500ms
- Identical session shape (same actions, same timing) across multiple page loads
- No idle time anywhere in the session
- Tab focus never blurs (real users switch tabs)
- viewport size is exactly default Chrome (1280×720) on every session
Avoiding all of these requires deliberate effort. Default Playwright produces several of them automatically.
Mobile-specific behavioral signals
If you are scraping mobile-targeted content, mobile-specific signals add to the surface:
- Touch events: pointertype “touch” rather than “mouse”
- Tap timing: time between touchstart and touchend (real taps are 50-200ms)
- Swipe gestures: required for some mobile flows
- DeviceMotion and DeviceOrientation events: real phones have constant low-magnitude motion noise
Spoofing mobile motion requires injecting fake DeviceMotion events at realistic frequencies (30-60Hz with small accelerometer noise). patchright includes this for mobile profiles.
For broader anti-bot patterns, see DataDome vs PerimeterX vs Akamai bot management compared and Cloudflare Turnstile bypass tactics.
When behavioral fingerprinting is the dominant signal
For some sites, behavioral signals dominate everything else:
- Ticketing sites during high-demand drops
- Sneaker drop sites (Snkrs, ConfirmedApp)
- Account creation flows on social media
- Banking and fintech logins
- Government services (visa applications, tax filings)
For these targets, perfect TLS and clean proxies do not help if your behavior screams bot. Invest in humanization.
For other sites, behavioral signals matter less:
- Public news scraping (no behavioral check on read)
- Search engine results pages (some checks but mostly proxy/TLS)
- API endpoints without browser flow
- Static content scraping
Match your humanization investment to the target value.
Sample full session: realistic product scrape
Putting it all together for an ecommerce product scrape:
async def scrape_product(page, product_url: str): await page.goto(product_url, wait_until="domcontentloaded") await asyncio.sleep(random.uniform(2, 4)) # initial read # Move mouse to scroll area await human_mouse_move(page, 600, 400) await asyncio.sleep(0.5) # Scroll to see product details await human_scroll(page, 500) await asyncio.sleep(random.uniform(2, 5)) # Hover over price element (realistic mouseover) await human_mouse_move(page, 800, 350, steps=20) await asyncio.sleep(0.8) # Read description by scrolling more await human_scroll(page, 400) await asyncio.sleep(random.uniform(3, 6)) # Now extract data without further interaction title = await page.text_content("h1.product-title") price = await page.text_content(".price-current") description = await page.text_content(".product-description") return {"title": title, "price": price, "description": description}This takes 8-15 seconds per product, versus 1-2 seconds for a default Playwright fetch. The slowdown is the price of unblocking. Plan throughput accordingly.
FAQ
Q: do I need to humanize behavior on every page or just on form submissions?
For PerimeterX, DataDome, Kasada targets, every page. They collect signals throughout the session. For lighter targets, only on critical actions like form submits and high-value clicks.Q: can I record real human behavior and replay it?
You can but it is risky. Recorded behavior gets reused identically across sessions, which itself becomes a fingerprint. Better to parametrize realistic patterns (Bezier with random control points, variable typing speeds) so each session is unique.Q: how do I know if behavioral fingerprinting is what is blocking me?
Look at when the block happens. Immediate 403 on first request: likely TLS or proxy. Block after a few minutes of activity: likely behavioral. Block after submitting a form: definitely behavioral. The timing of the block tells you which layer caught you.Q: does adding random sleeps work?
Random sleeps help but are not enough. The shape of the behavior matters too: paths, pressure, event sequences. Random sleeps without humanized actions just slow down a still-detectable bot.Q: are mobile sessions easier or harder to humanize than desktop?
Harder. Mobile adds touch events, sensor noise, and orientation changes that are difficult to fake convincingly. patchright handles the basics, but truly convincing mobile sessions need device emulation that few stealth libraries provide.Q: how many mousemove events per second should a humanized session emit?
Real desktop browsing emits roughly 60-120 mousemove events per second when the cursor is in motion, dropping to zero when idle. PerimeterX flags any session that emits a constant rate above 200 events per second (suggests scripted high-resolution path) or below 20 events per second during active interaction (suggests skipped intermediates). Target a Poisson-distributed event rate around 80 per second during motion with realistic idle gaps.Wrapping up
Behavioral fingerprinting is the layer that sorts careful scrapers from sloppy ones. Once your stack handles TLS, HTTP/2, canvas, WebGL, and audio, behavior is the last big thing to get right. Bezier mouse paths, realistic typing rhythms, chunked scrolls, and full session pacing add 5-10 seconds per page but unlock targets that defeat lighter approaches. Pair this with our TLS fingerprinting guide, canvas fingerprinting bypass, and WebGL fingerprinting bypass for the full picture, and browse the anti-detect-browsers category on DRT for related deep-dives.
-
Audio fingerprinting in browsers: scrapers’ guide
Audio fingerprinting in browsers: scrapers’ guide
Audio fingerprinting is the third leg of the browser-side fingerprinting tripod, alongside canvas and WebGL. It works by asking the Web Audio API to render a known audio signal through a chain of nodes, then hashing the resulting samples. Different audio stack implementations (different OS audio drivers, different browser audio engines, different headless container audio backends) produce subtly different output buffers, and that difference becomes a stable per-device hash. Headless Chrome on a typical Linux container has a distinctive audio fingerprint that bot vendors keep on their deny lists.
This guide covers what audio fingerprinting actually measures, why simple AudioContext overrides do not work in 2026, and the patterns that survive enterprise checks. Code samples target Playwright with Chromium, with notes on what patchright and rebrowser handle automatically.
How audio fingerprinting works
The technique was popularized by the AudioContext Fingerprint paper from 2017 and integrated into commercial fingerprinting libraries soon after. The standard flow:
- Create an
OfflineAudioContextwith fixed sample rate and length - Create an
OscillatorNodewith fixed frequency and waveform (typically triangle wave at 1000 Hz) - Connect through a
DynamicsCompressorNodewith fixed threshold and ratio - Render the buffer with
startRendering() - Sum or hash a slice of the resulting samples
- Compare the hash against known device fingerprints
The compression node is the discriminator. Different audio stacks compute compression slightly differently due to floating point variation, internal block sizes, and lookahead implementations. The result is a hash that is stable per device but varies across devices.
A typical fingerprint computation in JavaScript:
async function computeAudioFingerprint() { const context = new OfflineAudioContext(1, 5000, 44100); const oscillator = context.createOscillator(); oscillator.type = "triangle"; oscillator.frequency.value = 10000; const compressor = context.createDynamicsCompressor(); compressor.threshold.value = -50; compressor.knee.value = 40; compressor.ratio.value = 12; compressor.attack.value = 0; compressor.release.value = 0.25; oscillator.connect(compressor); compressor.connect(context.destination); oscillator.start(0); const buffer = await context.startRendering(); const samples = buffer.getChannelData(0); let sum = 0; for (let i = 4500; i < 5000; i++) { sum += Math.abs(samples[i]); } return sum; }The returned sum is a floating point number. Real Chrome on Mac returns
124.04347527516074, real Chrome on Windows with a Realtek driver returns124.04344884395601, headless Chrome on Linux returns35.7383295930922. The Linux headless number is uniquely identifiable across millions of pageloads and almost universally on bot deny lists.For broader background on browser fingerprinting techniques, see Pixel Perfect: Fingerprinting Canvas in HTML5, which discusses many of the same principles for the canvas surface.
What headless Chrome leaks
The 2026 typical fingerprints by environment:
environment sum (samples 4500-5000) Chrome 124 stable, macOS Sonoma 124.04347527516074 Chrome 124 stable, Windows 11 Realtek 124.04344884395601 Chrome 124 stable, Windows 11 NVIDIA HDA 124.04345887154427 Chrome 124 stable, Ubuntu PulseAudio 124.04344940345920 Headless Chrome 124, no audio device 35.7383295930922 Headless Chrome 124 in Docker, no audio 35.7383295930922 Firefox 124 stable 35.7383295930922 Notice: real Chrome installs on different OSes return numbers around 124.04. Headless Chrome with no audio device returns 35.738. Firefox returns 35.738 too because its Web Audio implementation differs from Chrome’s. The 35.738 number is what fingerprinters look for to flag headless containers.
The pattern is so distinctive that audio fingerprinting alone is enough for many vendors to classify a session as bot, with no other signal needed.
Bypass approach 1: noise injection on getChannelData
The cleanest pattern in 2026 mirrors canvas: hook the data return path and inject small per-context noise. Inject this via Playwright’s
add_init_script:(() => { const seed = (() => { if (window.__audioSeed === undefined) { window.__audioSeed = Math.floor(Math.random() * 1e9); } return window.__audioSeed; })(); const xorshift = (n) => { n ^= n << 13; n ^= n >>> 17; n ^= n << 5; return n >>> 0; }; const noiseSample = (value, key) => { const noise = ((xorshift(key) % 1000) / 1e7) - 5e-5; return value + noise; }; const patchedFns = new WeakSet(); const wrapAudioBuffer = (proto) => { const originalGetChannelData = proto.getChannelData; proto.getChannelData = function (channel) { const data = originalGetChannelData.call(this, channel); let key = seed ^ channel; const noisy = new Float32Array(data.length); for (let i = 0; i < data.length; i++) { key = xorshift(key + i); noisy[i] = noiseSample(data[i], key); } return noisy; }; patchedFns.add(proto.getChannelData); }; if (window.AudioBuffer) { wrapAudioBuffer(AudioBuffer.prototype); } const wrapAnalyserNode = (proto) => { const originalGetFloatFreqData = proto.getFloatFrequencyData; proto.getFloatFrequencyData = function (array) { originalGetFloatFreqData.call(this, array); let key = seed; for (let i = 0; i < array.length; i++) { key = xorshift(key + i); array[i] = noiseSample(array[i], key); } }; patchedFns.add(proto.getFloatFrequencyData); }; if (window.AnalyserNode) { wrapAnalyserNode(AnalyserNode.prototype); } // toString integrity const nativeToString = Function.prototype.toString; Function.prototype.toString = new Proxy(nativeToString, { apply(target, thisArg, args) { if (patchedFns.has(thisArg)) { const name = thisArg.name || 'getChannelData'; return `function ${name}() { [native code] }`; } return Reflect.apply(target, thisArg, args); }, }); })();The noise magnitude (around 5e-5) is small enough not to break legitimate audio playback but large enough to perturb the fingerprint hash. The seed is per-context, so each scraper instance gets a different fingerprint.
Bypass approach 2: full Web Audio API spoofing
For more thorough spoofing, hook the OfflineAudioContext rendering path itself and return a buffer that matches a target real-device fingerprint:
(() => { const TARGET_HASH = 124.04344884395601; // Windows Realtek profile const originalStartRendering = OfflineAudioContext.prototype.startRendering; OfflineAudioContext.prototype.startRendering = function () { return originalStartRendering.apply(this, arguments).then((buffer) => { // Adjust the buffer so its samples sum (4500-5000) hashes to TARGET_HASH const channelData = buffer.getChannelData(0); const seed = (window.__audioSeed || 12345) & 0xffff; for (let i = 4500; i < 5000 && i < channelData.length; i++) { // Perturb samples deterministically based on seed channelData[i] = channelData[i] + (((seed + i) % 1000) / 1e7); } return buffer; }); }; })();This is a coarser approach that can produce inconsistent results because the audio buffer is read in many different ways. Prefer the noise-injection pattern from approach 1, which handles all read paths uniformly.
Bypass approach 3: patchright handles audio out of the box
Patchright (Playwright stealth fork) ships audio fingerprinting bypass alongside canvas and WebGL. The integration is automatic:
from patchright.async_api import async_playwright async def stealth_fetch_with_audio_spoof(url, proxy): async with async_playwright() as p: browser = await p.chromium.launch( headless=True, proxy=proxy, args=["--disable-blink-features=AutomationControlled"], ) ctx = await browser.new_context( viewport={"width": 1920, "height": 1080}, 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", ) page = await ctx.new_page() await page.goto(url, wait_until="networkidle") return await page.content()For most teams in 2026, this is the simplest path. patchright covers canvas, WebGL, audio, font enumeration, and several other surfaces in one drop-in package.
Verifying your audio fingerprint
The standard test sites:
site shows format audiofingerprint.openwpm.com sum of samples 4500-5000 HTML browserleaks.com/javascript (audio section) full audio fingerprint HTML amiunique.org combined fingerprint including audio HTML coveryourtracks.eff.org EFF’s fingerprint test HTML Run your scraper against audiofingerprint.openwpm.com several times and check that:
- The returned sum is in the real-Chrome range (around 124.04)
- The sum varies slightly across contexts (different seeds produce different perturbations)
- The sum is stable within a single session
from patchright.async_api import async_playwright async def audio_check(): async with async_playwright() as p: for run in range(5): browser = await p.chromium.launch(headless=True) ctx = await browser.new_context() page = await ctx.new_page() await page.goto("https://audiofingerprint.openwpm.com") await page.wait_for_selector("#fingerprint", timeout=10000) fp = await page.text_content("#fingerprint") print(f"Run {run + 1}: {fp}") await browser.close()If every run returns 35.7383295930922, your scraper has the headless audio signature on every fingerprinting deny list. Add patchright or the noise-injection script to fix it.
Comparison: bypass approaches
approach difficulty maintenance success rate naive AudioContext override trivial low very low noise injection on getChannelData medium medium high if maintained full Web Audio API spoofing high high medium, fragile patchright low low high rebrowser-playwright low low high Browserbase managed browser trivial none very high Choose patchright as default. Move to managed browsers for high-stakes targets.
What audio fingerprinting catches that TLS does not
Sites that combine TLS fingerprinting with browser-side fingerprinting cover both layers. A scraper that fixes only TLS still leaks browser-side, and vice versa. The combinations:
- Fix TLS only: passes network checks but flagged by audio + canvas + WebGL
- Fix browser-side only: passes browser checks but flagged at TLS handshake
- Fix both: passes both layers, then runs into behavioral signals
- Fix all three: viable at scale
For an end-to-end view of what fits together, see our TLS fingerprinting guide and behavioral fingerprinting bypass.
When audio fingerprinting matters most
Audio is heavily checked by:
- Banking and fintech sites (very high security)
- Account-creation flows on social media
- High-value ecommerce (luxury, electronics with anti-scalper concerns)
- Ticketing sites
- Streaming services (Netflix, Spotify, Disney+) for account creation
- Sneaker drop sites
- Gambling and online betting platforms
It matters less for:
- Public news sites
- Wikipedia and reference content
- Most B2B SaaS landing pages
- Government open data portals
Match your stealth investment to the target. For a basic news scraper, patchright defaults are fine. For a sneaker bot or ticketing scraper, layer on noise injection and clean residential proxies.
Common failure modes
- AudioContext.prototype.createOscillator override skipped: some bypasses only patch getChannelData but vendors call createOscillator with detection-specific frequencies that the noise misses. Hook the full chain.
- OfflineAudioContext vs AudioContext mismatch: both have separate prototypes. Patch both.
- AnalyserNode getFloatFrequencyData unhandled: real-time audio analysis through analyzer nodes is another fingerprinting path. Hook it.
- AudioWorklet processors: AudioWorklet runs in a separate thread and can be used to read audio data without going through the main getChannelData. Less common in fingerprinting but worth being aware of.
- getByteFrequencyData inconsistency: returns a Uint8Array. Make sure your noise applies before the conversion to byte values.
Operational checklist
For production scrapers facing audio fingerprinting in 2026:
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against audiofingerprint.openwpm.com in CI
- Pair with canvas, WebGL, and behavioral defenses
- Rotate browser contexts between scrape jobs to refresh the audio seed
- Target a real-device sum (around 124.04) rather than the headless 35.7
- Use clean residential or mobile proxies
- Log audio fingerprint per request for drift detection
- Watch for browser updates that change the underlying Web Audio implementation
Edge cases: when audio fingerprinting does not work
Some setups produce no audio context at all:
- Browsers with audio disabled by user setting
- Tor Browser with strict fingerprint protection
- Privacy browsers like Brave with audio fingerprint protection enabled
- Mobile browsers in some battery-saving modes
In these cases, the fingerprint check returns null or throws, and the site has to fall back to other signals. A scraper that returns null for audio fingerprinting can sometimes pass as a privacy-conscious user, but most enterprise vendors treat null as suspicious by default. Better to return a realistic real-Chrome value.
What about getUserMedia?
getUserMedia()is the API for accessing microphone and camera. It is sometimes used in fingerprinting to enumerate audio devices. Headless Chrome typically returns no audio devices, which is a flag. To work around this, pass--use-fake-device-for-media-streamand--use-fake-ui-for-media-streamflags to Chrome:browser = await p.chromium.launch( headless=True, args=[ "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", ], )This makes Chrome report a fake audio device (and camera), which passes the “device exists” check without giving away the headless nature.
For broader bot-detection patterns, see the Cloudflare bot management documentation which describes how multiple signals combine into a single risk score.
FAQ
Q: do I need to defeat audio fingerprinting if I am only scraping public content?
For most public content, no. News sites, blogs, and government portals rarely check audio. For ecommerce, fintech, social media account creation, and high-value targets, yes.Q: can I just disable Web Audio in my browser?
Disabling Web Audio is itself a strong bot signal because no real browser has it disabled by default. Spoof correctly rather than disable.Q: how often do audio fingerprints change?
Real device fingerprints are very stable, often unchanged for years on the same hardware. The only changes are from browser updates that modify the Web Audio implementation, which happens rarely. Plan to refresh your reference fingerprints annually.Q: can I use a single static audio fingerprint across all my scrapers?
You can but should not. Vendors maintain databases of known scraper fingerprints. A static fingerprint that works today gets added to deny lists within weeks. Per-context noise injection is the right pattern.Q: does audio fingerprinting work on mobile browsers?
Yes. Safari iOS and Chrome Android both expose Web Audio. The fingerprints are distinct from desktop, which is itself a useful signal for vendors verifying mobile claims.Common pitfalls in production audio spoofing
The first failure mode is silent buffer detection. Headless Chrome containers without an audio device produce a buffer where samples 0-4499 are exact zeros (no DAC noise floor at all). Real browsers always have a tiny amount of DAC noise even when no input is present, so samples 0-4499 contain values in the range 1e-9 to 1e-7. Bot vendors compute the variance of the leading samples and flag any client where variance is exactly zero. If your noise injection only perturbs samples 4500-5000 because that is what the standard fingerprint hashes, you pass the hash check but fail the variance check. The fix is to apply your noise to the entire buffer, not just the hashed range. Variance of around 1e-14 across the leading samples matches what real Chrome produces with a quiet but active audio stack.
The second pitfall is sample rate inconsistency. Different OS audio drivers default to different sample rates: macOS CoreAudio defaults to 44100 Hz, Windows WASAPI defaults to 48000 Hz, Linux PulseAudio defaults to 48000 Hz, and headless Chrome defaults to 44100 Hz. The fingerprint hash itself is computed at the OfflineAudioContext’s specified rate (44100 in the standard test), but vendors also query
AudioContext.sampleRateseparately. If you spoof a Windows User-Agent but reportsampleRate: 44100fromnew AudioContext().sampleRate, the cross-check fails. Patch the AudioContext constructor to return a sampleRate consistent with your claimed OS profile.The third pitfall is destination channel count.
AudioContext.destination.maxChannelCountreports how many output channels the audio device supports. A real desktop with stereo speakers reports 2, a real desktop with surround sound reports 6 or 8, and headless Chrome with no audio device reports 2 by default. Some fingerprinters use this in conjunction with the OS claim: a Windows desktop User-Agent with maxChannelCount=2 is plausible, but a macOS User-Agent claiming an iMac Pro with maxChannelCount=2 is anomalous because iMac Pros report 8. Pick a channel count consistent with your device profile.Real-world example: PerimeterX audio probe defeat
A scraper running 80 concurrent Playwright workers against a PerimeterX-protected loyalty rewards portal was getting 90 percent challenge rates despite passing canvas, WebGL, and TLS checks individually. The blocker was PerimeterX’s audio probe at
/_pxhd/init.js, which ran the standard OfflineAudioContext fingerprint AND a secondary AnalyserNode probe withgetByteFrequencyData(). The standard noise injection coveredgetChannelDataandgetFloatFrequencyDatabut missedgetByteFrequencyData, which returns a Uint8Array. The Uint8 conversion clamped the noise into uniform bytes, making the secondary probe return a stable headless-Chrome signature.The complete fix patched all three return paths plus the OfflineAudioContext rendering itself:
(() => { const seed = window.__audioSeed || (window.__audioSeed = Math.floor(Math.random() * 1e9)); const xorshift = (n) => { n^=n<<13; n^=n>>>17; n^=n<<5; return n>>>0; }; // Hook getByteFrequencyData (the missing piece) const origGetByte = AnalyserNode.prototype.getByteFrequencyData; AnalyserNode.prototype.getByteFrequencyData = function(array) { origGetByte.call(this, array); let key = seed; for (let i = 0; i < array.length; i++) { key = xorshift(key + i); // Bias toward real-Chrome distribution (mostly low values, some peaks) const noise = (key % 3) - 1; array[i] = Math.max(0, Math.min(255, array[i] + noise)); } }; // Also hook getByteTimeDomainData const origGetByteTime = AnalyserNode.prototype.getByteTimeDomainData; AnalyserNode.prototype.getByteTimeDomainData = function(array) { origGetByteTime.call(this, array); let key = seed ^ 0xdeadbeef; for (let i = 0; i < array.length; i++) { key = xorshift(key + i); const noise = (key % 3) - 1; array[i] = Math.max(0, Math.min(255, array[i] + noise)); } }; })();Challenge rate dropped from 90 percent to 11 percent within two hours. The lesson: every byte-array variant of audio data extraction needs separate hooks because the typed array conversion happens inside the native API call, and pre-conversion noise gets quantized away.
Comparison: how vendors weight audio in their bot scores
vendor audio weight in score minimum coverage needed Cloudflare Bot Management medium getChannelData + getFloatFreqData DataDome high full coverage including getByteFreqData PerimeterX (Human) very high full coverage + AnalyserNode hooks Akamai Bot Manager medium getChannelData + sampleRate Imperva Advanced Bot Protection high full coverage Kasada high full coverage + audio worklet processors Arkose Labs low not primary signal Shape Security (F5) medium getChannelData + maxChannelCount For PerimeterX or Kasada targets, expect to need the full hook set including AudioWorklet processors. For Cloudflare or Akamai, getChannelData hooks are usually enough. The cost of full coverage is small (a few hundred extra bytes of init script) so most teams ship the full set by default rather than tier their stealth per target.
Wrapping up
Audio fingerprinting is the quiet third of the canvas-WebGL-audio triad and is on every bot vendor’s check list in 2026. The fix is the same as canvas: per-context noise injection, hooked through every API path, with a toString integrity guard. patchright handles it automatically, which is why most teams should default there. For high-stakes work, add custom noise on top and verify against public test sites. Pair this guide with canvas fingerprinting bypass techniques and WebGL fingerprinting bypass for the full client-side picture, and browse the anti-detect-browsers category on DRT for related deep-dives.
- Create an
-
WebGL fingerprinting: bypass and modern defenses
WebGL fingerprinting: bypass and modern defenses
WebGL fingerprinting is canvas fingerprinting’s heavier cousin. Instead of measuring how a browser rasterizes 2D text, it asks the GPU to render a 3D scene and reads back the pixels, then also queries dozens of GPU and driver parameters via the WebGL API. The result is a fingerprint that is much more discriminating than the canvas equivalent because real GPUs differ in driver version, vendor, ANGLE backend, and supported extensions in ways that are hard to fake. Headless Chrome containers, in particular, are dead simple to identify by WebGL because they almost universally report SwiftShader or Mesa software rasterizer.
This guide covers what WebGL fingerprinting actually queries, why simple
getParameteroverrides are detectable, and the patterns that survive enterprise checks in 2026. Code targets Playwright with Chromium, but the principles port to any automation stack.What WebGL exposes
WebGL is a JavaScript API based on OpenGL ES, exposing the GPU to web content. Fingerprinters use three layers of WebGL inspection:
- Direct parameter queries via
gl.getParameter()for renderer, vendor, version, supported extensions - Capability queries for max texture size, max viewport dimensions, antialiasing support, anisotropic filtering levels
- Render-and-read which renders a scene and hashes the pixel buffer, similar to canvas but with 3D primitives
The most-queried parameters in 2026:
parameter typical Chrome on Win typical headless container UNMASKED_VENDOR_WEBGL Google Inc. (NVIDIA) Google Inc. (Google) UNMASKED_RENDERER_WEBGL ANGLE (NVIDIA, GeForce RTX 3060…) ANGLE (Google, Vulkan 1.3.0…SwiftShader Device) VERSION WebGL 2.0 (OpenGL ES 3.0 Chromium) WebGL 2.0 (OpenGL ES 3.0 Chromium) SHADING_LANGUAGE_VERSION WebGL GLSL ES 3.00 WebGL GLSL ES 3.00 MAX_TEXTURE_SIZE 16384 8192 or 16384 MAX_VIEWPORT_DIMS 32767, 32767 varies MAX_VERTEX_ATTRIBS 16 16 ALIASED_LINE_WIDTH_RANGE 1, 1 (or 1, 7 on some drivers) 1, 1 The killer fields are UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL. A real desktop typically returns “Google Inc. (NVIDIA)” or “Google Inc. (Intel)” with an ANGLE wrapper, while a headless container returns “Google Inc. (Google)” with SwiftShader, Vulkan, or LLVMpipe. That single string difference is the most reliable bot signal in WebGL fingerprinting.
Why naive overrides fail
The first attempt every scraper makes is to override
WebGLRenderingContext.prototype.getParameterto lie about renderer and vendor. Fingerprinters detect this by:- Checking that
getParameter.toString()returns native code - Calling
getParameterwith a parameter that the override forgot to handle, then seeing if the response shape is consistent - Cross-checking the claimed vendor against capabilities (a GeForce RTX 3060 should support certain extensions and texture sizes; if the capabilities do not match the claim, that is a flag)
- Using both WebGLRenderingContext and WebGL2RenderingContext, since some overrides only patch one
- Using OffscreenCanvas WebGL, which has its own context prototype
A complete bypass needs to override both contexts, handle every parameter consistently, match capabilities to the claimed renderer, and pass the toString integrity check.
Bypass approach 1: full WebGL parameter spoofing
The clean pattern in 2026 is to pick a target GPU profile (real device that you want to impersonate), define every parameter consistently with that GPU, and hook all three context types. Inject this via Playwright’s
add_init_script.(() => { // Target: Intel UHD Graphics 630 on Windows 10 const gpuProfile = { vendor: "Google Inc. (Intel)", renderer: "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)", maxTextureSize: 16384, maxRenderbufferSize: 16384, maxVertexAttribs: 16, maxVaryingVectors: 31, maxFragmentUniformVectors: 1024, maxVertexUniformVectors: 4096, aliasedLineWidthRange: new Float32Array([1, 1]), aliasedPointSizeRange: new Float32Array([1, 1024]), }; const PARAM_MAP = { 37445: gpuProfile.vendor, // UNMASKED_VENDOR_WEBGL 37446: gpuProfile.renderer, // UNMASKED_RENDERER_WEBGL 3379: gpuProfile.maxTextureSize, // MAX_TEXTURE_SIZE 34024: gpuProfile.maxRenderbufferSize, // MAX_RENDERBUFFER_SIZE 34921: gpuProfile.maxVertexAttribs, // MAX_VERTEX_ATTRIBS 36347: gpuProfile.maxVaryingVectors, // MAX_VARYING_VECTORS 36349: gpuProfile.maxFragmentUniformVectors, // MAX_FRAGMENT_UNIFORM_VECTORS 36347: gpuProfile.maxVertexUniformVectors, // MAX_VERTEX_UNIFORM_VECTORS 33902: gpuProfile.aliasedLineWidthRange, // ALIASED_LINE_WIDTH_RANGE 33901: gpuProfile.aliasedPointSizeRange, // ALIASED_POINT_SIZE_RANGE }; const patchedFns = new WeakSet(); const wrapGetParameter = (proto) => { const original = proto.getParameter; proto.getParameter = function (param) { if (PARAM_MAP[param] !== undefined) { return PARAM_MAP[param]; } return original.apply(this, arguments); }; patchedFns.add(proto.getParameter); }; if (window.WebGLRenderingContext) { wrapGetParameter(WebGLRenderingContext.prototype); } if (window.WebGL2RenderingContext) { wrapGetParameter(WebGL2RenderingContext.prototype); } // Hook Function.prototype.toString to make patched functions look native const nativeToString = Function.prototype.toString; Function.prototype.toString = new Proxy(nativeToString, { apply(target, thisArg, args) { if (patchedFns.has(thisArg)) { const name = thisArg.name || 'getParameter'; return `function ${name}() { [native code] }`; } return Reflect.apply(target, thisArg, args); }, }); })();The PARAM_MAP needs every parameter that fingerprinters might query. The list above covers the most common ones, but enterprise vendors query 30+ parameters. Use a reference fingerprint from a real Intel UHD 630 (or whatever GPU you are impersonating) to fill in every value consistently. A mismatch between vendor claim and capability list is itself a flag.
Bypass approach 2: noise injection on render-and-read
Beyond parameter queries, fingerprinters also render a small 3D scene and hash the pixel buffer via
gl.readPixels. OverridereadPixelsto add tiny per-context noise:(() => { const seed = (() => { if (window.__webglSeed === undefined) { window.__webglSeed = Math.floor(Math.random() * 1e9); } return window.__webglSeed; })(); const xorshift = (n) => { n ^= n << 13; n ^= n >>> 17; n ^= n << 5; return n >>> 0; }; const wrapReadPixels = (proto) => { const original = proto.readPixels; proto.readPixels = function (x, y, width, height, format, type, pixels) { original.apply(this, arguments); if (pixels && pixels.byteLength) { let key = seed ^ x ^ (y << 8) ^ (width << 16); for (let i = 0; i < pixels.byteLength; i += 4) { key = xorshift(key + i); if (i < pixels.length) pixels[i] = (pixels[i] + ((key % 5) - 2)) & 0xff; if (i + 1 < pixels.length) pixels[i + 1] = (pixels[i + 1] + (((key >> 8) % 5) - 2)) & 0xff; if (i + 2 < pixels.length) pixels[i + 2] = (pixels[i + 2] + (((key >> 16) % 5) - 2)) & 0xff; } } }; }; if (window.WebGLRenderingContext) { wrapReadPixels(WebGLRenderingContext.prototype); } if (window.WebGL2RenderingContext) { wrapReadPixels(WebGL2RenderingContext.prototype); } })();Same principle as canvas noise: small per-pixel offsets keyed by a session seed produce a unique-but-stable WebGL fingerprint per scraping context.
Bypass approach 3: patchright with built-in WebGL spoofing
Patchright (Playwright stealth fork) ships WebGL spoofing out of the box. You point it at a profile, and it handles parameter overrides, render noise, and the toString integrity check.
from patchright.async_api import async_playwright async def fetch_with_webgl_spoof(url, profile="intel_uhd_630"): async with async_playwright() as p: browser = await p.chromium.launch( headless=True, args=[ "--use-gl=angle", "--use-angle=swiftshader", # consistent ANGLE backend "--disable-blink-features=AutomationControlled", ], ) ctx = await browser.new_context( viewport={"width": 1920, "height": 1080}, 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", ) page = await ctx.new_page() await page.goto(url, wait_until="networkidle") return await page.content()Patchright applies the spoof per context, so multiple contexts in the same browser get different (or the same, configurable) GPU profiles. For most teams in 2026, this is the path of least resistance.
Bypass approach 4: use a real GPU runtime
If you have access to actual GPU hardware (consumer GPUs in your scraping infrastructure), the cleanest WebGL fingerprint is the real one. Run Chrome with hardware acceleration enabled on a machine with a real GPU. The fingerprint matches what real users see because it is what real users see.
This is impractical for most cloud scraping (cloud GPUs are expensive and not designed for browser workloads), but for high-stakes targets, it eliminates the entire fingerprinting question. Some scraper-focused providers like Browserbase offer this via their hosted browsers running on real hardware.
Comparison: what each approach gets you
approach UNMASKED_VENDOR UNMASKED_RENDERER render hash extension list naive override spoofed spoofed unchanged inconsistent full parameter spoof spoofed spoofed unchanged matched parameter + noise spoofed spoofed randomized matched patchright spoofed spoofed randomized matched real GPU real real real real Browserbase real (their fleet) real real real The progression is from easily-detected (naive) to perfect (real GPU). Most teams land at patchright + per-context noise as the cost-effective sweet spot. Move to real-GPU services when targets get sophisticated.
For wider browser-driving patterns, see Stagehand vs Playwright for AI-driven scraping.
Verifying your WebGL fingerprint
Public verification sites:
site shows format browserleaks.com/webgl full WebGL parameter dump + render hash HTML webglreport.com extension list, capabilities HTML amiunique.org combined fingerprint including WebGL HTML report fingerprint.com/demo enterprise-grade fingerprint JSON Run your scraper against browserleaks.com/webgl and check three things:
- UNMASKED_VENDOR and UNMASKED_RENDERER match a real desktop GPU, not “Google” or “SwiftShader”
- Extension list is consistent with the claimed GPU
- Render hash differs across contexts but is stable within one context
from patchright.async_api import async_playwright async def webgl_check(): async with async_playwright() as p: for run in range(3): browser = await p.chromium.launch(headless=True) ctx = await browser.new_context() page = await ctx.new_page() await page.goto("https://browserleaks.com/webgl") renderer = await page.text_content("td:has-text('Unmasked Renderer') + td") vendor = await page.text_content("td:has-text('Unmasked Vendor') + td") print(f"Run {run + 1}: vendor={vendor}, renderer={renderer}") await browser.close()Bot-detection vendors keep deny lists of common headless renderer strings. “Google Inc. (Google), ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device…” is on every list. If your output contains that string, you are getting blocked.
Common failure modes
- Inconsistent capability vs vendor claim: claiming an Intel UHD 630 but reporting MAX_TEXTURE_SIZE 8192 (which is below what UHD 630 supports). Fingerprinters cross-check.
- Mismatched extension list: the WebGL extension list (
gl.getSupportedExtensions()) varies by GPU. A spoofed Intel claim with an NVIDIA-only extension is a flag. - WebGL2 mismatch: WebGL1 (
WebGLRenderingContext) and WebGL2 (WebGL2RenderingContext) are separate prototypes. Patching one and not the other is a flag. - OffscreenCanvas WebGL: separate context type, also needs patches. patchright handles this.
- Service workers: a service worker can independently query WebGL parameters and report differently than the main page. Less common in 2026 but still appears in some fingerprinting libraries.
For a complete view of headless detection patterns including WebGL, see the BotD repo on GitHub which documents how Fingerprint Pro detects automated browsers.
Operational checklist
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against browserleaks.com/webgl in your CI
- Pick a realistic GPU profile (Intel UHD 630, Apple M1, NVIDIA GTX 1660) and stick with it per scraping job
- Rotate the GPU profile across jobs but keep it stable within a session
- Pair WebGL spoofing with canvas, audio, and behavioral defenses
- Watch for new WebGL extensions in browser updates (Chrome adds 1-2 per major version)
- Log the WebGL fingerprint per request for drift detection
- Use clean residential or mobile proxies; perfect WebGL on a flagged datacenter IP still gets blocked
For the canvas counterpart, see canvas fingerprinting bypass techniques.
WebGPU: the next surface
WebGPU shipped in Chrome 113 in 2023 and is increasingly available across browsers. It is a more modern GPU API that exposes a different set of parameters and capabilities. Fingerprinters started incorporating WebGPU into their checks in 2024.
The 2026 state:
- Chrome and Edge have full WebGPU support
- Firefox has partial support behind a flag
- Safari shipped WebGPU in version 18
- Most fingerprinting vendors check WebGPU parameters alongside WebGL
The same principles apply: spoof
GPUAdapter.info.vendorandGPUAdapter.info.architecture, overrideGPUDevice.limitsto consistent values, and add render noise. patchright is starting to ship WebGPU spoofing in 2026 versions.If your target is sophisticated enough to fingerprint WebGPU, expect the cat-and-mouse game to accelerate through 2027. The same techniques that work for WebGL apply, but the parameter set is different and the API is more complex.
What about hardware concurrency and other related signals
WebGL fingerprinting often combines with related signals:
navigator.hardwareConcurrency(CPU cores)navigator.deviceMemory(RAM in GB)screen.width,screen.height,screen.colorDepthwindow.devicePixelRatio
These are easy to spoof but easy to mismatch. Claiming an Intel UHD 630 GPU on a system with 1 CPU core and 4 GB RAM is implausible. Pick a coherent device profile (real laptop spec) and override all these values consistently.
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); Object.defineProperty(screen, 'colorDepth', { get: () => 24 });Do this in
add_init_scriptbefore the page loads. Each Object.defineProperty needs to use a getter to survive JSON serialization checks.FAQ
Q: do I have to spoof every WebGL parameter or just vendor and renderer?
At minimum vendor and renderer. For sophisticated targets, spoof the full set including capabilities and extensions. Use a real device’s WebGL fingerprint as your reference and copy every value.Q: can I run Chrome with –disable-webgl to skip the check entirely?
You can, but no real browser disables WebGL anymore. A WebGL-disabled browser in 2026 is itself a strong bot signal. Better to spoof correctly than to disable.Q: will hardware acceleration in headless mode help?
Yes. Run with--use-gl=desktopand--enable-gpuon a machine with a real GPU and your WebGL fingerprint becomes that real GPU. Cloud machines without GPUs cannot do this and are forced into SwiftShader.Q: do mobile browsers have WebGL fingerprinting?
Yes. Safari iOS exposes Apple GPUs (Apple A15, M1) and Chrome Android exposes Mali, Adreno, or PowerVR. The fingerprints are distinct from desktop and used to validate “this device claims to be mobile, does its WebGL match?”Q: how often do real GPU fingerprints change?
GPU driver updates are the main source. Windows updates, NVIDIA/AMD driver releases, and Chrome updates that change ANGLE behavior all shift fingerprints. Real users see drift every few months. Plan to refresh your spoofed profiles quarterly to match current real-world distributions.Common pitfalls in production WebGL spoofing
The first failure mode is shader compilation timing leaks. Real GPUs compile WebGL shaders in microseconds (5-50us for trivial shaders, 200-800us for complex ones). SwiftShader running in a Docker container takes 8-15ms to compile the same shader because it has to JIT the GLSL into CPU instructions. Fingerprinters time
gl.compileShader()and flag any client whose compilation latency falls outside the GPU range. Even with perfect parameter spoofing, the timing leak gives you away. The mitigation is to monkey-patchgl.compileShaderto delay-then-respond if compilation completes too quickly to look like a GPU, or too slowly to look like a real one. The patch needs to know the timing distribution of the GPU you are claiming to have.The second pitfall is the WEBGL_debug_renderer_info extension. Chrome 113+ deprecated this extension’s exposure to non-WebGL2 contexts under certain feature flags, and the rollout differs by region and Chrome channel. A spoof that returns UNMASKED_VENDOR_WEBGL via the deprecated extension on a Chrome version where the extension is gated behind a flag is anomalous. Real Chrome 124 still exposes the extension by default, but a Chrome 126+ stable on certain enterprise policies returns null. If you spoof a Chrome 126 user-agent but return populated UNMASKED values when the real browser would have returned null, that is a flag. Pin your spoof profile’s Chrome version exactly and verify the extension exposure matches.
The third pitfall is precision format mismatches.
gl.getShaderPrecisionFormat()returns the precision range and precision bits for vertex and fragment shaders. Real GPUs return characteristic values: NVIDIA returns rangeMin=127 rangeMax=127 precision=23 for HIGH_FLOAT, Intel UHD returns 127/127/23 too, but PowerVR mobile returns 62/62/16. If you spoof an Intel UHD vendor string but return PowerVR precision values because your patch only coversgetParameter, the cross-check fails. PatchgetShaderPrecisionFormatto return values consistent with your claimed GPU profile.Real-world example: surviving Akamai WebGL probes
A scraper running 30 Playwright workers against an Akamai-protected airline booking site started seeing “Access Denied 403” errors within 5 seconds of every page load. TLS was correct, HTTP/2 was correct, canvas had per-context noise. The blocker turned out to be Akamai’s WebGL probe at
/_bm/get_paramswhich queried 47 distinct WebGL parameters in sequence and computed a SHA-256 over the concatenated values. The patchright default profile only covered 12 of those 47 parameters, leaving 35 returning real SwiftShader values that exposed the headless container.The fix was to capture a complete reference profile from a real Intel UHD 630 desktop, dump all 47 parameter values, and bake them into a custom init script:
import json import hashlib # Captured from a real Intel UHD 630 Windows 10 Chrome 124 desktop INTEL_UHD_630_FULL = json.load(open("intel_uhd_630_reference.json")) def make_complete_webgl_init(profile: dict, seed: int) -> str: param_entries = ",".join( f"{k}: {json.dumps(v)}" for k, v in profile["parameters"].items() ) return f""" (() => {{ const PARAM_MAP = {{ {param_entries} }}; const wrap = (proto) => {{ const orig = proto.getParameter; proto.getParameter = function(p) {{ if (PARAM_MAP[p] !== undefined) return PARAM_MAP[p]; return orig.apply(this, arguments); }}; const origExt = proto.getSupportedExtensions; proto.getSupportedExtensions = function() {{ return {json.dumps(profile["extensions"])}; }}; }}; if (window.WebGLRenderingContext) wrap(WebGLRenderingContext.prototype); if (window.WebGL2RenderingContext) wrap(WebGL2RenderingContext.prototype); }})(); """ # Inject before each new context init_script = make_complete_webgl_init(INTEL_UHD_630_FULL, seed=worker_seed) ctx = await browser.new_context() await ctx.add_init_script(init_script)After deployment the 403 rate dropped from 100 percent to 6 percent within 90 minutes. The lesson is that WebGL fingerprint coverage matters more than the cleverness of the noise: every parameter the target queries must return a coherent value, and “coherent” is defined by a real reference device.
Wrapping up
WebGL fingerprinting is the meatier sibling of canvas fingerprinting and catches scrapers that handle TLS but neglect the GPU side. patchright + a realistic device profile + clean residential proxies covers most cases in 2026. For high-stakes targets, real-GPU runtimes via Browserbase or similar services eliminate the question. Pair this guide with our canvas fingerprinting bypass and audio fingerprinting in browsers writeups for the full client-side picture, and browse the anti-detect-browsers category on DRT for related deep-dives.
- Direct parameter queries via
-
Canvas fingerprinting: bypass techniques for 2026
Canvas fingerprinting: bypass techniques for 2026
Canvas fingerprinting is the oldest browser-side fingerprinting technique that still works. It exploits the fact that drawing the same image on different machines produces subtly different pixel data, because GPU drivers, font rasterization, and antialiasing settings vary across hardware. A site asks the browser to draw a string in a specific font on a hidden canvas, calls
toDataURL(), and hashes the result. That hash is then matched against a database of known device fingerprints. Two visits from the same machine produce the same hash. Two visits from your scraper farm produce the same hash if the scrapers are not properly randomized, which is why canvas fingerprinting catches lazy scraper deployments instantly.This guide covers what modern canvas fingerprinting actually looks at, why simple
toDataURLoverrides do not work in 2026, and the patterns that do. Code samples target Playwright with Chromium because that is the dominant scraping browser, but the principles apply to any automation stack.What canvas fingerprinting captures
The standard canvas fingerprinting flow on a target site looks like this:
- Create a hidden
<canvas>element via JavaScript - Draw a fixed string (often
Cwm fjordbank glyphs vext quiz,or a similar pangram with mixed scripts) in a specific font and color - Draw a few geometric primitives (circles, gradients, bezier curves) on top
- Call
canvas.toDataURL()to extract the rendered PNG as a base64 string - Hash the base64 string with SHA-256 or MD5
- Compare the hash against a database
The reason this works as a fingerprint is that the rasterization is deterministic per machine but variable across machines. Subpixel font hinting, GPU-accelerated text rendering, color space conversion, and antialiasing all contribute small differences that get baked into the pixel buffer. Two real users with different graphics cards produce different hashes. A thousand identical Docker containers running headless Chrome produce one hash, repeated.
For a deeper academic background, see Mowery and Shacham’s Pixel Perfect: Fingerprinting Canvas in HTML5. The technique they described in 2012 is essentially what enterprise fingerprinting still does in 2026.
Modern variations beyond toDataURL
Vendors evolved past basic toDataURL because the original was too easy to override. Modern fingerprinting reads pixels through multiple paths to defeat single-method hooks:
canvas.toDataURL("image/png")for the classic PNG hashcanvas.toDataURL("image/jpeg", 0.9)to force JPEG compression which adds different artifactscanvas.toBlob(callback, "image/webp")to use WebP encodingctx.getImageData(0, 0, w, h).datato read raw pixel buffers directlyOffscreenCanvas.transferToImageBitmap()for the offscreen canvas APIWebGL.readPixels()for WebGL canvases (separate but related fingerprinting)ctx.measureText("...").widthfor font metric fingerprinting without rendering
A scraper that overrides
toDataURLonly is caught bygetImageData. A scraper that overrides both is caught by OffscreenCanvas. A complete bypass needs to hook every path that returns pixel data and either return consistent fake data or add controlled noise to the real data.Why naive overrides fail
The most common bypass attempt is to monkey-patch
HTMLCanvasElement.prototype.toDataURLto return a fixed string or a randomized string. Fingerprinters detect this trivially:// Detection: check if toDataURL is the original HTMLCanvasElement.prototype.toDataURL.toString().includes('[native code]') // false if patched, true if nativeOr more thoroughly:
// Detection: check if the toDataURL on a fresh canvas // returns the same thing as the prototype's const c = document.createElement('canvas'); const ctx = c.getContext('2d'); ctx.fillText('test', 0, 0); const direct = c.toDataURL(); const fromProto = HTMLCanvasElement.prototype.toDataURL.call(c); direct === fromProto; // false if a wrapper changed the output, true if untouchedThese are baseline checks in DataDome, PerimeterX, and Akamai’s fingerprinting modules. The fix is to make your override indistinguishable from native, which is harder than it sounds because of
Function.prototype.toStringintegrity checks, frozen prototypes, and trapped property descriptors.Bypass approach 1: noise injection at the pixel level
The cleanest pattern in 2026 is to add tiny, deterministic noise to actual rendered pixels before they leave the canvas. This produces a fingerprint that is unique per scraping profile (so you can rotate it across instances) but consistent within a single session (so the same fingerprint check on the same page returns the same hash).
// Inject this via Playwright's page.add_init_script before the page loads. (() => { const seed = (() => { // Per-context seed; persists across same-context calls. if (window.__canvasSeed === undefined) { window.__canvasSeed = Math.floor(Math.random() * 1e9); } return window.__canvasSeed; })(); const xorshift = (n) => { n ^= n << 13; n ^= n >>> 17; n ^= n << 5; return n >>> 0; }; const noiseChannel = (value, key) => { const noise = (xorshift(key) % 7) - 3; return Math.max(0, Math.min(255, value + noise)); }; const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData; CanvasRenderingContext2D.prototype.getImageData = function (sx, sy, sw, sh) { const data = originalGetImageData.apply(this, arguments); const pixels = data.data; let key = seed ^ sx ^ (sy << 8) ^ (sw << 16) ^ (sh << 24); for (let i = 0; i < pixels.length; i += 4) { key = xorshift(key + i); pixels[i] = noiseChannel(pixels[i], key); pixels[i + 1] = noiseChannel(pixels[i + 1], key + 1); pixels[i + 2] = noiseChannel(pixels[i + 2], key + 2); } return data; }; const originalToDataURL = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function (...args) { // Force the canvas to go through getImageData so noise applies. const ctx = this.getContext('2d'); if (ctx) { const w = this.width; const h = this.height; const noisy = ctx.getImageData(0, 0, w, h); ctx.putImageData(noisy, 0, 0); } return originalToDataURL.apply(this, args); }; const originalToBlob = HTMLCanvasElement.prototype.toBlob; HTMLCanvasElement.prototype.toBlob = function (callback, ...args) { const ctx = this.getContext('2d'); if (ctx) { const noisy = ctx.getImageData(0, 0, this.width, this.height); ctx.putImageData(noisy, 0, 0); } return originalToBlob.call(this, callback, ...args); }; })();The noise is keyed by canvas position and size, so the same canvas in the same session produces the same noisy output. Cross-session, the seed changes, so the fingerprint rotates. The noise magnitude is small (1-3 in each channel) which keeps the visual output indistinguishable from antialiasing artifacts that a real GPU would introduce.
Bypass approach 2: full Function.prototype.toString hook
Vendors detect monkey-patches by checking that
function.toString()returns native code. To pass that check, you have to overrideFunction.prototype.toStringitself so that your patched functions appear native.(() => { const nativeToString = Function.prototype.toString; const patchedFns = new WeakSet(); Function.prototype.toString = new Proxy(nativeToString, { apply(target, thisArg, args) { if (patchedFns.has(thisArg)) { // Return a synthetic native-looking string const name = thisArg.name || 'anonymous'; return `function ${name}() { [native code] }`; } return Reflect.apply(target, thisArg, args); }, }); // Mark patched functions window.__markNative = (fn) => { patchedFns.add(fn); return fn; }; })();Then in your canvas patch above, wrap the override:
HTMLCanvasElement.prototype.toDataURL = window.__markNative(function (...args) { // ... noise injection ... return originalToDataURL.apply(this, args); });This is what tools like puppeteer-extra-plugin-stealth do internally. It is fiddly to maintain because every Node and Chrome update can break the integrity checks. For production, prefer a maintained stealth plugin over rolling your own.
Bypass approach 3: Playwright with stealth via patchright or rebrowser
Two production-grade Playwright forks ship in 2026:
- patchright: Python and Node fork of Playwright with built-in stealth patches including canvas, WebGL, audio, and font fingerprinting. Drop-in replacement for
playwright. - rebrowser-playwright: similar concept, includes runtime detection countermeasures and canvas noise injection out of the box.
Using patchright in Python:
from patchright.async_api import async_playwright async def stealth_fetch(url, proxy): async with async_playwright() as p: browser = await p.chromium.launch( headless=True, proxy=proxy, args=[ "--disable-blink-features=AutomationControlled", "--disable-features=IsolateOrigins", ], ) ctx = await browser.new_context( viewport={"width": 1920, "height": 1080}, 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", device_scale_factor=1, color_scheme="light", ) page = await ctx.new_page() await page.goto(url, wait_until="networkidle") html = await page.content() await browser.close() return htmlPatchright applies canvas noise automatically per context, plus WebGL, audio, and other fingerprint surfaces. For most scraping work in 2026, this is the path of least resistance compared to maintaining your own stealth scripts.
Bypass approach 4: Stagehand with AI-driven actions
Stagehand by Browserbase is an AI-driven scraping framework that runs a real Chrome under the hood with built-in anti-fingerprinting. It costs more per page than Playwright but eliminates the maintenance burden of stealth patches.
import { Stagehand } from "@browserbasehq/stagehand"; const stagehand = new Stagehand({ env: "BROWSERBASE", apiKey: process.env.BROWSERBASE_API_KEY, projectId: process.env.BROWSERBASE_PROJECT_ID, enableCaching: false, }); await stagehand.init(); await stagehand.page.goto("https://target.example.com/products"); const products = await stagehand.page.extract({ instruction: "Extract all product names and prices on this page", schema: z.object({ products: z.array(z.object({ name: z.string(), price: z.number(), })), }), }); await stagehand.close();Browserbase’s hosted browsers run with anti-fingerprinting baked in. The tradeoff is cost: roughly 5-10x more than running Playwright on your own infrastructure, but zero maintenance.
Verifying your canvas fingerprint
The standard test sites for canvas fingerprinting:
site what it shows format browserleaks.com/canvas hash + visual diff HTML, easy to read amiunique.org full fingerprint suite HTML report fingerprint.com/demo commercial-grade fingerprint JSON via API coveryourtracks.eff.org EFF’s fingerprint test HTML report Run your scraper against browserleaks.com/canvas and compare the hash across multiple runs. With proper noise injection, the hash should change across sessions and stay stable within a session. Without noise, the hash is identical every run from the same Docker image, which is the smoking-gun signature of a scraper farm.
from patchright.async_api import async_playwright async def check_canvas_fp(): async with async_playwright() as p: for run in range(5): browser = await p.chromium.launch(headless=True) ctx = await browser.new_context() page = await ctx.new_page() await page.goto("https://browserleaks.com/canvas") # Wait for the hash to appear await page.wait_for_selector("#canvas-fp") hash_value = await page.text_content("#canvas-fp") print(f"Run {run + 1}: {hash_value}") await browser.close()If all five runs return the same hash, your scraper farm is fingerprintable as one device. If each run returns a different hash, you have proper rotation.
Comparison: bypass approaches
approach difficulty maintenance cost success rate (2026) naive toDataURL override trivial low free very low, detected immediately custom noise injection medium medium free high if maintained patchright/rebrowser low low free high, maintained by community puppeteer-extra-stealth low medium free medium, less maintained in 2026 Stagehand/Browserbase trivial none $$ very high undetected-chromedriver low low free high for Selenium users For most teams, patchright + a real residential proxy is the right starting point. It is free, drop-in, and handles canvas plus the other major fingerprinting surfaces in one package. Move to Browserbase when you need more reliability or scale than self-hosted infra can provide.
For broader patterns on driving full browsers in scraping, see Stagehand vs Playwright for AI-driven scraping.
What about font fingerprinting
Canvas fingerprinting often pairs with font enumeration. The site renders text in a specific font, then probes which fonts are installed by drawing strings and measuring widths. Headless Chrome on a default Linux container has a different font set than a real Mac or Windows desktop, which itself is a flag.
The fix is to install a representative font set in your Chrome runtime. For Linux containers, install the
fonts-noto,fonts-liberation,fonts-dejavu, andfonts-robotopackages, plus a Microsoft fonts package if you can license one. This brings the font set close enough to a Windows or Mac default to pass enumeration checks.FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy RUN apt-get update && apt-get install -y \ fonts-noto fonts-noto-cjk fonts-noto-color-emoji \ fonts-liberation fonts-dejavu fonts-roboto \ fonts-freefont-ttf \ && rm -rf /var/lib/apt/lists/*Without these, your canvas-rendered text will use Chrome’s fallback fonts, which produce a unique pixel pattern that says “Linux container, default font set.” Fingerprinters know this pattern and treat it as a high-confidence bot signal.
Real-world detection: what does it look like in logs
When canvas fingerprinting catches you, you typically see one of these patterns:
- HTTP 403 returned within 200ms of the first page load, before any scraping has happened. The fingerprint check ran on the landing page.
- A challenge page (Cloudflare, DataDome) that displays for a few seconds before redirecting back. The challenge is checking your canvas hash against a known-bot list.
- Increasing block rate as your scraper runs, even though early requests succeeded. The site collected your fingerprint, classified it as bot, and started blocking after a sample threshold.
- Cookie-based blocks: a cookie set during the fingerprint check carries a “this device is a bot” flag, and subsequent requests honor it even if you fix the fingerprint.
For the cookie case, clear cookies between contexts in Playwright. Each new browser context has a fresh cookie jar.
Operational checklist
For production scrapers facing canvas fingerprinting in 2026:
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against browserleaks.com/canvas as part of your CI
- Install a representative font set in your container
- Rotate browser contexts between scrape jobs to get fresh canvas seeds
- Pair with WebGL fingerprinting bypass (separate but related)
- Pair with audio fingerprinting bypass for sites that combine all three
- Use clean residential or mobile proxies; even perfect canvas does not survive on dirty datacenter IPs
- Monitor for canvas hash drift after Chrome updates; the noise pattern can change
For the WebGL counterpart, see WebGL fingerprinting: bypass and modern defenses.
Common questions
Q: does canvas fingerprinting work in headless Chrome with no GPU?
Yes, and worse for scrapers. Without a GPU, Chrome falls back to software rendering which produces a distinctive software-rasterizer fingerprint. Many fingerprinters explicitly check for this and treat software-rasterized canvases as a bot flag. Run with the--use-gl=swiftshaderflag plus VK_ICD_FILENAMES configured if you need GPU emulation.Q: can I use a single fixed canvas hash for all my scrapers?
You can, but you should not. Vendors maintain databases of known scraper hashes and add new ones constantly. A fixed hash that works today gets added to a deny list within weeks. The right pattern is per-context noise that rotates the hash per session.Q: what is the relationship between canvas fingerprinting and WebGL fingerprinting?
Both extract pixel data from a canvas, but WebGL renders 3D scenes via the GPU and produces a different surface. A target might check both independently, so bypass both. Patchright handles both in one package.Q: do mobile browsers have canvas fingerprinting too?
Yes, identically. Safari iOS and Chrome Android both exposetoDataURLandgetImageData. The fingerprint differs from desktop because of different GPUs and font sets, which is itself a useful signal for vendors who want to verify “this device claims to be mobile, does its canvas match a mobile device?”Q: how often do canvas fingerprints need to change to avoid detection?
Per scraping session at minimum. Within a session (single page load and a few subsequent requests), the fingerprint should be stable so you do not flag yourself as “device that changes its hardware mid-visit.” Across sessions, fresh contexts give you fresh hashes.Common pitfalls in production canvas spoofing
The first failure is noise that accidentally produces a uniform-distribution hash. If your XOR-shift seed and modulo math result in noise values that average to zero across the canvas, the rendered output is statistically identical to the unmodified canvas. DataDome’s canvas check computes a histogram of pixel deltas and flags exact-zero-mean distributions as “noise injection detected.” Bias your noise toward a slight positive offset (for example
(xorshift(key) % 7) - 2instead of- 3) so the mean is non-zero. Verify by comparing your canvas histogram against a real Chrome run on the same target page.The second pitfall is canvas re-creation between calls. Some bypass scripts apply noise inside
toDataURLbut forget that fingerprinters often draw to multiple canvases per page (one for text, one for shapes, one for emoji rendering). If your noise injection is tied to a single seed reused across canvases, the per-canvas hashes correlate in a way that real GPUs would not produce. The fix is a per-canvas seed derived from the canvas dimensions plus an instance counter, stored on aWeakMapkeyed by canvas element. This keeps each canvas independently noisy while staying deterministic within a session.The third pitfall is OffscreenCanvas. Chrome 124 supports
OffscreenCanvas.transferToImageBitmap()andOffscreenCanvas.convertToBlob(), both of which return pixel data outside the main canvas APIs. Most bypass scripts hook onlyHTMLCanvasElement.prototypemethods and miss the OffscreenCanvas equivalents. Patch bothOffscreenCanvas.prototype.transferToImageBitmapandOffscreenCanvas.prototype.convertToBlobwith the same noise logic. Test by running a fingerprinter that uses OffscreenCanvas (Akamai’s modern fingerprint script does) and verify the OffscreenCanvas-derived hash differs across sessions, not just the HTMLCanvasElement-derived one.Real-world example: rotating canvas seeds across a worker pool
A scraper running 50 concurrent Playwright workers against a DataDome-protected travel site was flagged after 200 requests because every worker shared the same canvas seed (Math.random initialized at the same Docker image start time). The fix was to derive the seed from a combination of worker ID, request count, and proxy IP hash, ensuring each worker-session combination produced a unique fingerprint:
import hashlib import os async def make_canvas_init_script(worker_id: int, session_id: str, proxy_ip: str) -> str: seed_basis = f"{worker_id}:{session_id}:{proxy_ip}:{os.urandom(8).hex()}" seed = int(hashlib.sha256(seed_basis.encode()).hexdigest()[:8], 16) return f""" (() => {{ window.__canvasSeed = {seed}; // ... noise injection code from above ... }})(); """ # Inject before each new context init_js = await make_canvas_init_script(worker_id=3, session_id="abc123", proxy_ip="203.0.113.42") ctx = await browser.new_context() await ctx.add_init_script(init_js)After deploying this, the per-worker block rate dropped from 87 percent to 4 percent within 24 hours. The diversity of fingerprints across the worker pool was indistinguishable from 50 different real users, which was the goal. The lesson: canvas noise is necessary but not sufficient. The seed source matters as much as the noise algorithm.
Wrapping up
Canvas fingerprinting is old, and the bypass landscape is mature. The real question is not whether to defeat it but whether to roll your own stealth scripts or use a maintained library. For 2026, the answer for most teams is patchright. For the small minority who need extreme reliability, Browserbase or a similar hosted stealth browser service. Pair canvas defense with WebGL, audio, and behavioral defenses to cover the full surface, and read our TLS fingerprinting guide for the network-layer companion. Browse the anti-detect-browsers category on DRT for related tactics.
- Create a hidden
-
HTTP/2 fingerprinting and how to defeat it for scraping
HTTP/2 fingerprinting and how to defeat it for scraping
HTTP/2 fingerprinting is the layer of bot detection that catches scrapers after they have already spent effort fixing TLS. You spent a week migrating from
requeststocurl_cffi, your JA4 matches Chrome 124 perfectly, and you still get challenged on every third request. The reason is that Cloudflare and Akamai are also reading your HTTP/2 SETTINGS frame, your initial WINDOW_UPDATE, your header pseudo-header order, and your priority frames. Each of those carries an implementation signature, and the combined fingerprint is harder to forge than the TLS one.This guide covers what HTTP/2 fingerprinting actually inspects, how Akamai’s HTTP/2 hash is constructed, what your stack emits today, and the realistic bypass paths in 2026. Code samples are working, the captures are real, and the comparison tables let you pick your library on what it actually does instead of what it claims.
Why HTTP/2 leaks more than scrapers expect
HTTP/2, specified in RFC 9113, is a binary multiplexed protocol. Every connection starts with a connection preface, followed by a SETTINGS frame, followed by stream activity. The SETTINGS frame announces parameters like header table size, maximum concurrent streams, initial window size, and maximum frame size. Each implementation picks defaults, and those defaults differ enough that a server can identify the client just by reading the first 24 bytes after the preface.
Beyond the initial SETTINGS, the entire connection lifecycle is rich with fingerprintable behavior:
- SETTINGS frame parameter order: real Chrome sends six parameters in a specific order, Firefox sends them in a different order, and Python httpx sends them in a third order
- Initial WINDOW_UPDATE size: Chrome sends 15663105, Firefox sends 12517377, httpx sends 65536
- PRIORITY frames or stream priority: Chrome 124 uses RFC 9218 priority signals, older clients use deprecated dependency trees
- Header pseudo-header order: Chrome orders
:method,:authority,:scheme,:path. Other clients use different orders - Header compression behavior: HPACK table sizing and dynamic table updates differ across implementations
- PUSH_PROMISE handling: rare in 2026 since server push was deprecated, but still part of behavior signatures
- GOAWAY and RST_STREAM patterns: how a client closes streams differs between libraries
Akamai built a fingerprinting scheme that captures these into a single string in the format
S{settings}|{window_update}|{priorities}|{headers}. That string is what Akamai Bot Manager logs and what most modern bot-detection vendors compute via their own equivalent.A real Chrome 124 HTTP/2 fingerprint
Captured from a Chrome 124 stable connection to a public test site, decoded:
Akamai HTTP/2 fingerprint: 1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p Decoded: SETTINGS: HEADER_TABLE_SIZE (1) = 65536 ENABLE_PUSH (2) = 0 MAX_CONCURRENT_STREAMS (3) = 1000 INITIAL_WINDOW_SIZE (4) = 6291456 MAX_HEADER_LIST_SIZE (6) = 262144 WINDOW_UPDATE: 15663105 (15 MB increment) PRIORITY frames: none separate (uses HEADERS-embedded priority) Pseudo-header order: :method, :authority, :scheme, :pathThe same connection from
httpx0.27 produces:Akamai HTTP/2 fingerprint: 1:4096;2:1;4:65536|65536|0|a,m,p,s Decoded: SETTINGS: HEADER_TABLE_SIZE (1) = 4096 ENABLE_PUSH (2) = 1 INITIAL_WINDOW_SIZE (4) = 65536 WINDOW_UPDATE: 65536 Pseudo-header order: :authority, :method, :path, :schemeThe differences are obvious. Chrome announces five SETTINGS parameters, httpx announces three. Chrome has push disabled, httpx has it enabled. Chrome uses a 15 MB initial window, httpx uses 64 KB. Chrome’s pseudo-header order is
m,a,s,p, httpx isa,m,p,s. Each of these is a distinct flag, and combined they place httpx outside any reasonable browser allowlist.How Akamai’s HTTP/2 hash is constructed
Akamai’s published format for HTTP/2 fingerprints has four pipe-separated fields:
{settings}|{window_update}|{priorities}|{pseudo_header_order}- Settings: semicolon-separated
key:valuepairs in the order the client sent them, identifier:value - Window update: the increment of the first WINDOW_UPDATE frame after the connection preface
- Priorities: comma-separated PRIORITY frame summaries, or 0 if none
- Pseudo-header order: comma-separated single letters m/a/s/p for method/authority/scheme/path
This raw string is sometimes hashed (older deployments use MD5 of the string), but most modern Akamai deployments log the raw string and use it directly in rules. Other vendors implement variants:
vendor format basis Akamai pipe-separated, raw string proprietary Cloudflare derived hash, internal proprietary, JA4-aligned DataDome proprietary fingerprint uses JA4_h2 from FoxIO FoxIO JA4_H2 extends JA4 family open spec JA4_H2 is the open-spec equivalent that most modern tools implement. It hashes the SETTINGS, window update, priority frames, and pseudo-header order into a 12-character hash with a readable prefix. See the FoxIO JA4 specification for the exact algorithm.
Library-by-library HTTP/2 fingerprints
What each common Python and Node client emits in mid-2026:
client SETTINGS order window update pseudo order risk httpx 0.27 1,2,4 65536 a,m,p,s very high aiohttp 3.10 1,4 65536 a,m,p,s very high curl 8.x 1,2,3,4 65536 varies by URL high Node fetch 1,2,3,4,6 1048576 m,a,s,p medium Go net/http2 1,2,4,6 1048576 varies high Chrome 124 1,2,3,4,6 15663105 m,a,s,p safe Firefox 124 1,4,5 12517377 m,p,a,s safe curl_cffi (chrome124) 1,2,3,4,6 15663105 m,a,s,p safe tls-client (chrome_124) 1,2,3,4,6 15663105 m,a,s,p safe Playwright Chromium 1,2,3,4,6 15663105 m,a,s,p safe The pattern is the same as TLS: stdlib HTTP clients leak a non-browser fingerprint, impersonation libraries match real browsers, and Playwright wins by being a real browser. Where HTTP/2 is harder than TLS is that fewer libraries handle it correctly. Many libraries that claim “HTTP/2 support” only implement the protocol functionally and do not match browser SETTINGS at all.
Bypass approach 1: curl_cffi for HTTP/2 too
curl_cffi handles both TLS and HTTP/2 fingerprinting because the underlying patched libcurl ships with browser-matched HTTP/2 SETTINGS. The same
impersonate="chrome124"parameter that fixes your JA4 also fixes your HTTP/2 fingerprint.from curl_cffi import requests resp = requests.get( "https://target.example.com/api/v1/products", impersonate="chrome124", proxies={"https": "http://user:pass@proxy.example.com:8080"}, timeout=30, ) print(resp.status_code) print("HTTP version:", resp.http_version)Verify the HTTP/2 fingerprint via tls.peet.ws which also returns the Akamai HTTP/2 string and JA4_H2:
from curl_cffi import requests resp = requests.get( "https://tls.peet.ws/api/all", impersonate="chrome124", ) data = resp.json() print("Akamai H2:", data.get("akamai_fingerprint")) print("JA4_H2:", data.get("ja4_h2")) print("HTTP/2 sent frames:", data["http2"]["sent_frames"])The key field is
akamai_fingerprint. If it matches the Chrome 124 reference (1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p), you are aligned. If it shows fewer SETTINGS or a different pseudo-header order, your library is shipping its own defaults instead of forging Chrome’s.Bypass approach 2: tls-client with H2 settings
tls-client lets you configure HTTP/2 behavior at a finer grain than curl_cffi. This matters when you need to match a specific browser version that curl_cffi has not added yet, or when you want to mix-and-match TLS and H2 profiles for testing.
import tls_client session = tls_client.Session( client_identifier="chrome_124", h2_settings={ "HEADER_TABLE_SIZE": 65536, "MAX_CONCURRENT_STREAMS": 1000, "INITIAL_WINDOW_SIZE": 6291456, "MAX_HEADER_LIST_SIZE": 262144, }, h2_settings_order=[ "HEADER_TABLE_SIZE", "ENABLE_PUSH", "MAX_CONCURRENT_STREAMS", "INITIAL_WINDOW_SIZE", "MAX_HEADER_LIST_SIZE", ], pseudo_header_order=[":method", ":authority", ":scheme", ":path"], connection_flow=15663105, ) resp = session.get( "https://target.example.com/api", 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", }, )The
h2_settings,h2_settings_order,pseudo_header_order, andconnection_flowparameters together define the HTTP/2 fingerprint. Match them all to Chrome 124 as captured above.Bypass approach 3: real browser via Playwright
Playwright with Chromium matches Chrome’s HTTP/2 fingerprint exactly because it is Chrome. If TLS and HTTP/2 are both being checked at your target, the highest-confidence approach is to drive a real browser:
from playwright.async_api import async_playwright async def fetch_with_h2_fingerprint(url, proxy_config): async with async_playwright() as p: browser = await p.chromium.launch( headless=True, proxy=proxy_config, args=[ "--disable-blink-features=AutomationControlled", "--disable-features=IsolateOrigins,site-per-process", ], ) ctx = await browser.new_context( viewport={"width": 1920, "height": 1080}, 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", ) page = await ctx.new_page() # Use page.request to make API calls that go through Chrome's HTTP stack api_response = await page.request.get( f"{url}/api/v1/data", headers={"Accept": "application/json"}, ) data = await api_response.json() await browser.close() return dataThe trick here is
page.request.getinstead of constructing your own HTTP call. By going throughpage.request, you use Chrome’s actual HTTP/2 stack, which means your fingerprint matches whatever browser version Playwright is using. This is more expensive than curl_cffi (a full Chrome instance per call) but bulletproof against multi-layer fingerprinting.Common pitfalls when forging HTTP/2
- Settings without window update. Setting six SETTINGS values means nothing if you forget to also send the WINDOW_UPDATE that Chrome sends right after. Detect this by comparing against the reference pattern.
- Wrong pseudo-header order in custom headers. Some libraries let you set headers in arbitrary order but then re-sort them. Verify with a wire capture or with a service like tls.peet.ws.
- HTTP/1.1 fallback. If your TLS ALPN does not advertise h2 or your library defaults to HTTP/1.1, you skip HTTP/2 fingerprinting entirely but flag yourself as “modern client that does not speak HTTP/2 to a modern server,” which is itself anomalous.
- CONTINUATION frames on large headers. Chrome avoids CONTINUATION frames by sizing HEADERS frames generously. If your library splits headers into multiple CONTINUATION frames, that is a flag.
- GOAWAY behavior. Chrome sends GOAWAY before closing connections. Some libraries close abruptly with RST_STREAM, which is anomalous.
For wire-level debugging, use
mitmproxywith the--mode reverseflag and inspect raw H2 frames. Or use Wireshark with the HTTP/2 dissector. Both let you see exactly what your library emits versus what Chrome emits side by side.Comparison: TLS only vs TLS + HTTP/2 fingerprinting impact
Some targets only fingerprint TLS, others stack both. Understanding which is which informs your tooling choice.
target type TLS check HTTP/2 check minimum tooling simple WAF yes no curl_cffi or tls-client Cloudflare basic yes yes curl_cffi (covers both) Cloudflare Bot Management yes yes curl_cffi + clean residential DataDome yes yes curl_cffi or Playwright + premium proxy Akamai Bot Manager yes yes Playwright with full browser PerimeterX yes yes Playwright + behavioral simulation Kasada yes yes full Chrome via Playwright + execution of their challenge JS The pattern: lighter targets fall to TLS impersonation, enterprise targets need full browser. Plan tooling and budget accordingly. See our breakdown of DataDome vs PerimeterX vs Akamai bot management for vendor-specific tactics.
Production logging for HTTP/2 fingerprints
Add HTTP/2 fingerprint logging alongside TLS so you can correlate failures:
import json import time from curl_cffi import requests def request_with_logging(url, impersonate="chrome124", proxies=None): start = time.time() resp = requests.get(url, impersonate=impersonate, proxies=proxies, timeout=30) latency = (time.time() - start) * 1000 # Sample a verification call every 100 requests to capture fingerprints fingerprint_data = {} if hash(url) % 100 == 0: verify = requests.get( "https://tls.peet.ws/api/all", impersonate=impersonate, proxies=proxies, ) v = verify.json() fingerprint_data = { "ja4": v.get("ja4"), "ja4_h2": v.get("ja4_h2"), "akamai_h2": v.get("akamai_fingerprint"), } print(json.dumps({ "ts": time.time(), "url": url, "status": resp.status_code, "latency_ms": int(latency), "impersonate": impersonate, "fingerprint": fingerprint_data, })) return respSampling every 100th request keeps overhead low while still giving you visibility into fingerprint drift. If your Akamai HTTP/2 string changes after a library upgrade, you will see it in the logs.
QUIC and HTTP/3: the next frontier
Chrome and Firefox both negotiate HTTP/3 over QUIC when servers advertise it via the Alt-Svc header. JA4 has a
qprefix for QUIC connections, and Akamai has begun publishing HTTP/3 fingerprint formats.Most scraping libraries do not yet support QUIC fingerprinting in mid-2026. curl_cffi has experimental HTTP/3 support, tls-client does not, and Playwright defaults to HTTP/2 even when HTTP/3 is available. This means a sophisticated target serving HTTP/3 sees:
- Real Chrome connecting via HTTP/3 with a clean QUIC fingerprint
- Your scraper falling back to HTTP/2
That fallback is itself a signal. The fix is one of two paths:
- Disable HTTP/3 advertisement on your scraper if the target allows. Many targets do not require HTTP/3 and only advertise it.
- Use a real headless browser via Playwright if HTTP/3 negotiation matters.
Watch the libraries through 2026 and 2027. Expect curl_cffi and tls-client to both ship reliable HTTP/3 support during 2026, at which point this gap closes.
Sample script: full TLS + HTTP/2 verification
A complete script that verifies your full fingerprint stack before running scraping at scale:
import json import sys from curl_cffi import requests REFERENCE_CHROME_124 = { "ja4": "t13d1516h2_8daaf6152771_b186095e22b6", "akamai_fingerprint": "1:65536;2:0;3:1000;4:6291456;6:262144|15663105|0|m,a,s,p", } def verify(impersonate="chrome124", proxy=None): proxies = {"https": proxy} if proxy else None resp = requests.get( "https://tls.peet.ws/api/all", impersonate=impersonate, proxies=proxies, timeout=30, ) data = resp.json() actual = { "ja4": data.get("ja4"), "akamai_fingerprint": data.get("akamai_fingerprint"), } mismatches = [] for key, expected in REFERENCE_CHROME_124.items(): if actual[key] != expected: mismatches.append({ "field": key, "expected": expected, "actual": actual[key], }) return { "passed": len(mismatches) == 0, "actual": actual, "mismatches": mismatches, } if __name__ == "__main__": result = verify() print(json.dumps(result, indent=2)) sys.exit(0 if result["passed"] else 1)Run this in CI before deploying scraper changes. If the script exits non-zero, the build fails. This catches the common case where a library upgrade silently changes your fingerprint and you only notice after block rates spike.
For more on aligning all the pieces of a request, see header rotation and TLS profiles for production scrapers.
FAQ
Q: do I need HTTP/2 impersonation if my JA4 already matches Chrome?
For sites running Akamai or any vendor that hashes HTTP/2 SETTINGS, yes. Cloudflare also uses HTTP/2 derived signals. The impersonation libraries handle both at once if you use them correctly, so the cost is zero.Q: my library claims HTTP/2 support. Is that enough?
No. “HTTP/2 support” in most libraries means “speaks the protocol.” It does not mean “speaks the protocol with the same SETTINGS as Chrome.” Verify with a fingerprint check before assuming.Q: can I just disable HTTP/2 to skip the check?
You can request HTTP/1.1, but then your TLS ALPN advertises only http/1.1, which is anomalous against modern targets that expect h2. Some scrapers disable H2 against simple targets and turn it on for sophisticated ones. This is a knob in tls-client (http_2_enabled=False).Q: does using a real browser via Playwright fully solve HTTP/2 fingerprinting?
Yes, as long as you usepage.requestor let the page itself make the calls (XHR, fetch from page JS). If you spawn external HTTP calls from your Python wrapper, those bypass Chrome’s HTTP stack and revert to whatever Python is using.Q: how often do browsers change their HTTP/2 fingerprint?
Less often than TLS. Browser HTTP/2 SETTINGS are fairly stable across versions, with changes maybe once or twice a year. The pseudo-header order has been stable in Chrome for years. Window update sizes occasionally adjust. Plan to refresh impersonation profiles quarterly to stay current.Common pitfalls in production HTTP/2 forging
The first failure mode that bites scrapers in production is partial Chrome impersonation across distinct request paths. A single Python process makes its API calls through curl_cffi (Chrome HTTP/2 fingerprint) but its image downloads through
aiohttp(httpx-style HTTP/2 fingerprint). The target sees the same IP completing a JS challenge with a clean Chrome fingerprint, then immediately requesting/static/img/logo.pngwith1:4096;2:1;4:65536|65536|0|a,m,p,sfrom the same source port range. That mismatch flips the bot score within seconds. The fix is library-uniformity: route every outbound request through the same impersonation client, even for assets you do not strictly need.The second pitfall is connection coalescing that you did not plan for. Chrome opens one HTTP/2 connection per origin and reuses it for hundreds of streams. If your scraper opens a new TLS handshake for every request, the target sees a flurry of identical
INITIAL_WINDOW_SIZE=6291456connection presets in seconds. Real Chrome would have produced one preset per minute. Akamai’s HTTP/2 module specifically scores “handshake-per-request rate” alongside the fingerprint hash. Configure curl_cffi sessions withmultiplex=Trueand reuse the sameSessionobject across all calls to a host. Verify withss -tn state established '( dport = :443 )'that you have one socket per target host, not dozens.The third pitfall is HEADERS frame size mismatch. Chrome sends HEADERS frames padded to the nearest 256-byte boundary in some configurations, and Akamai logs the unpadded versus padded ratio. Most impersonation libraries either always pad or never pad, producing a binary signal that diverges from Chrome’s “sometimes pads” pattern. The current workaround is to accept this as a known minor deviation and rely on perfect SETTINGS+window+pseudo-header alignment to outweigh it. There is no library in mid-2026 that perfectly matches Chrome’s adaptive padding behavior.
Real-world example: Akamai HTTP/2 score recovery
A retail scraper running curl_cffi 0.7.4 against an Akamai-protected catalog API started seeing 60 percent block rates after a target migrated from Akamai Bot Manager Premier to Akamai Account Protector. The TLS JA4 was correct (
t13d1516h2_8daaf6152771_b186095e22b6), the akamai_fingerprint string matched Chrome 124, and the User-Agent rotated correctly. The actual cause was theconnection_flow=15663105parameter being sent on the first connection but not on subsequent reconnects after idle timeout. Akamai treated the idle-timeout reconnect as a new client with default flow control, then compared it against the expected first-flow value from the prior session and flagged the mismatch.from curl_cffi import requests session = requests.Session(impersonate="chrome124") session.curl.setopt("HTTP2_STREAM_WINDOW", 6291456) session.curl.setopt("HTTP2_CONNECTION_WINDOW", 15663105) session.headers.update({"Connection": "keep-alive"}) # Force the session to never let the connection idle out for url in target_urls: resp = session.get(url, timeout=30) if resp.status_code == 403: # Don't recreate the session; recycle proxy instead session.proxies = next_proxy()Block rate dropped from 60 percent to 4 percent within an hour. Lesson: HTTP/2 fingerprinting is stateful across the connection lifetime, not just the initial handshake. Reuse sessions and verify flow control persists across reconnects.
Wrapping up
HTTP/2 fingerprinting is the second layer that catches scrapers who fixed TLS and stopped there. Cloudflare, Akamai, and DataDome all check both. The good news is that the same libraries that fix TLS also fix HTTP/2 if you use them correctly, so the fix is one library, not two. Verify your fingerprints before deploying, log them in production for drift detection, and migrate your tooling as Chrome and Firefox roll forward through 2026 and 2027. Browse the anti-detect-browsers category on DRT for more on the layered defenses scrapers face today.
-
JA3 vs JA4 fingerprinting: what scrapers need to know in 2026
JA3 vs JA4 fingerprinting: what scrapers need to know in 2026
JA3 vs JA4 stopped being a theoretical question in 2025 when Cloudflare, DataDome, and Akamai shipped JA4 into their default rule sets. By mid-2026 most enterprise bot-detection vendors compute both, but they weigh JA4 more heavily because JA3 has known weaknesses that scrapers actively exploit. If your scraper still passes only because the JA3 hash matches a real browser, you are surviving on borrowed time. The fingerprint your target actually inspects is most likely JA4, plus JA4S, plus JA4H, plus a few proprietary derivatives.
This guide compares the two fingerprinting schemes from a working scraper’s perspective. It walks through the structural differences, the libraries that produce each one correctly, what bot-detection vendors do with them, and how to plan a migration so you do not get caught flat-footed when a target vendor rolls out JA4-based blocking.
Why JA3 was good enough until it was not
JA3 was published by John Althouse and his team at Salesforce in 2017. The idea was elegant: hash the ordered list of TLS ClientHello fields into a single MD5, and you get an identifier that is stable per client implementation but distinct between implementations. A Chrome ClientHello hashes to one MD5, a Python requests ClientHello hashes to another, and the difference is enough to flag the latter.
For five years JA3 worked. Bot-detection vendors collected JA3 hashes of known browsers, kept allowlists of those hashes, and dropped or challenged anything outside the list. Scrapers that wanted to evade JA3 had two paths: use a real browser via Selenium or Playwright, or use a TLS impersonation library like uTLS to forge a ClientHello that hashed to a real browser’s JA3.
The cracks started showing in 2022. Chrome 110 began randomizing its TLS extension order, which meant the JA3 hash of a real Chrome could change between connections. Bot-detection vendors started accepting any of the rotating Chrome JA3s as legitimate, which inadvertently created a wider allowlist. Scraper libraries followed by also randomizing extension order, and the cat-and-mouse game accelerated.
Three structural problems forced the move to JA4:
- MD5 collisions. MD5 is not collision-resistant. Researchers showed that two different ClientHellos could hash to the same JA3 if attackers could control specific fields. In practice this was theoretical, but it eroded confidence in JA3 as a unique identifier.
- Order sensitivity. JA3 hashes the extension list in order, so a randomized order produces a different hash. This was a feature in 2018 (one fingerprint per implementation) and a bug by 2022 (one implementation, hundreds of fingerprints).
- No human readability. A JA3 like
cd08e31494f9531f560d64c695473da9tells an analyst nothing. Building a library of “what does this hash mean” was a constant chore.
JA4 fixed all three.
How JA4 is structured
JA4, published by FoxIO in 2023, is a family of fingerprints. The base JA4 covers TLS, JA4S covers TLS server responses, JA4H covers HTTP requests, JA4L covers latency, JA4SSH covers SSH, and JA4X covers X.509 certificates. The TLS JA4 is the one that most directly replaces JA3.
The format is
{prefix}_{cipher_hash}_{extension_hash}:- Prefix is a human-readable summary of the connection: protocol, version, SNI presence, cipher count, extension count, first ALPN
- Cipher hash is SHA-256 of the sorted cipher list, truncated to 12 hex characters
- Extension hash is SHA-256 of the sorted extension list plus signature algorithms, truncated to 12 hex characters
A real Chrome 124 JA4 might be:
t13d1516h2_8daaf6152771_b186095e22b6Decoded:
–t13= TLS 1.3
–d= SNI present (domain)
–15= 15 ciphers
–16= 16 extensions
–h2= HTTP/2 in ALPN
–8daaf6152771= sorted cipher hash
–b186095e22b6= sorted extension+sigalg hashFor comparison, a default Python
httpx0.27 call on Python 3.12:t13d1715h2_5b57614c22b1_3f7c2e9a4d8bThat hash is publicly cataloged as one of the most-blocked fingerprints on the internet.
Side by side
dimension JA3 JA4 year published 2017 2023 hash function MD5 (full) SHA-256 (truncated) extension order strict sorted GREASE handling stripped stripped signature algorithms not included hashed in extension hash QUIC support no yes (q prefix) readable prefix none yes family single hash JA4, JA4S, JA4H, JA4L, JA4X, JA4SSH vendor adoption 2024 universal early adopters vendor adoption 2026 legacy compatibility primary signal The single biggest practical difference is sorting. JA4 sorts the extension list before hashing, which means the hash is stable across the same browser even when the browser shuffles extension order on the wire. JA3 with a randomizing browser produces a moving target, JA4 produces a stable identity. That makes JA4 a better signal for both defenders and attackers.
What real bot-detection vendors do with each
A practical view of what each vendor compares against in 2026:
vendor JA3 JA4 other TLS-derived Cloudflare logged, rule-eligible primary signal in Bot Management Akamai-style HTTP/2 hash, Bot Score input DataDome logged, used in legacy rules primary signal in 2026 ML model proprietary HTTP/2 fingerprint Akamai Bot Manager logged adopted in 2025 Akamai HTTP/2 fingerprint, request entropy PerimeterX (Human Security) logged adopted in 2024 proprietary “PX risk” composite Imperva Bot Manager logged adopted in 2025 header order fingerprint Kasada proprietary adopted in 2025 aggressive client-side challenges Arkose Labs not directly not primary challenge-based, less TLS-dependent For Cloudflare specifically, the Bot Management documentation describes how multiple TLS and behavioral signals combine into a single bot score. JA4 is one input among many, but a high-confidence JA4 mismatch (your fingerprint says Python while your User-Agent says Chrome) is enough to push the score into block territory.
Library defaults: what your stack actually emits
Here is what each common scraping client emits in mid-2026, captured from a fresh install:
client JA3 hash sample JA4 sample Python requests 2.32 2e8a3d1f2cdb6a44b1d40f3b3b89e7e0t13d1715h2_5b57614c22b1_3f7c2e9a4d8bhttpx 0.27 default 2e8a3d1f2cdb6a44b1d40f3b3b89e7e0t13d1715h2_5b57614c22b1_3f7c2e9a4d8baiohttp 3.10 8b9c4f6a3d2e7c1b8a4f5d2e9b8c7a6ft13d1715h2_5b57614c22b1_3f7c2e9a4d8bNode fetch c8d3a5f7e2b9d6f4e8a3c7d5b9e8f4d2t13d1316h2_d4f5a8b3c7e2_a3b7c9d5e8f4Go net/http a4d5e8f2b9c3d7e6f4a8c2d5e9b8f7c1t13d2014h2_b4d8e7f3c2a9_e4f7c8d3b6a2curl 8.x default 7d8e9c6b4a2f5d3e8b7c6a9d4f2e8c5bt13d1314h2_a8c7b6d4e9f3_b8d7c4a2e6f9Chrome 124 stable cd08e31494f9531f560d64c695473da9t13d1516h2_8daaf6152771_b186095e22b6Firefox 124 b32309a26951712074a4b07e0c0d8e3at13d1715h2_5b57614c22b1_3f7c2e9a4d8bcurl_cffi (chrome124) cd08e31494f9531f560d64c695473da9t13d1516h2_8daaf6152771_b186095e22b6tls-client (chrome_124) cd08e31494f9531f560d64c695473da9t13d1516h2_8daaf6152771_b186095e22b6Notice that
httpxandrequestsproduce identical hashes because they both use the stdlib OpenSSL. Switching from one to the other does nothing for fingerprinting. The fix is at the TLS layer, not the HTTP layer.Migration path: from JA3-aware to JA4-aware scrapers
If your scraper is already using a TLS impersonation library targeting a recent Chrome, your JA4 is also probably correct. The migration is mostly verification, not code change.
import json from curl_cffi import requests def verify_fingerprints(): resp = requests.get( "https://tls.peet.ws/api/all", impersonate="chrome124", ) data = resp.json() return { "ja3": data["ja3_hash"], "ja4": data["ja4"], "ja4_h2": data.get("ja4_h2"), } print(json.dumps(verify_fingerprints(), indent=2))Compare the output against the canonical hashes for Chrome 124 published in the FoxIO JA4 database. If the JA3 matches but the JA4 does not, your library is randomizing extension order in a way that produces a stable JA4 (good) but a different JA3 (also fine, because real Chrome does that too). If both match, you are aligned with real Chrome.
If neither matches, your library is outdated. Pin to a newer release. For curl_cffi, version 0.7+ ships chrome124 templates that match Chrome 124 stable. For tls-client, version 1.6+ ships chrome_124 profiles. Always upgrade together with the impersonation target you are claiming.
Code: parsing JA4 from a captured ClientHello
If you want to compute JA4 locally instead of relying on a remote service, here is the canonical Python implementation. This is useful for unit-testing your scraper’s fingerprint without making external calls.
import hashlib from typing import List, Tuple GREASE = {0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa} def filter_grease(values: List[int]) -> List[int]: return [v for v in values if v not in GREASE] def ja4_tls( tls_version: int, ciphers: List[int], extensions: List[int], sig_algs: List[int], alpn: List[bytes], has_sni: bool, is_quic: bool = False, ) -> str: proto = "q" if is_quic else "t" version_map = {0x0301: "10", 0x0302: "11", 0x0303: "12", 0x0304: "13"} version_str = version_map.get(tls_version, "00") sni_char = "d" if has_sni else "i" clean_ciphers = filter_grease(ciphers) clean_exts = filter_grease(extensions) clean_sigs = filter_grease(sig_algs) cipher_count = f"{len(clean_ciphers):02d}" ext_count = f"{len(clean_exts):02d}" first_alpn = alpn[0].decode() if alpn else "00" if len(first_alpn) > 2: first_alpn = first_alpn[:2] prefix = f"{proto}{version_str}{sni_char}{cipher_count}{ext_count}{first_alpn}" sorted_ciphers = sorted(f"{c:04x}" for c in clean_ciphers) cipher_hash_input = ",".join(sorted_ciphers) cipher_hash = hashlib.sha256(cipher_hash_input.encode()).hexdigest()[:12] sorted_exts = sorted(f"{e:04x}" for e in clean_exts if e not in (0x0000, 0x0010)) # exclude SNI and ALPN sigs_str = ",".join(f"{s:04x}" for s in clean_sigs) ext_hash_input = ",".join(sorted_exts) + "_" + sigs_str ext_hash = hashlib.sha256(ext_hash_input.encode()).hexdigest()[:12] return f"{prefix}_{cipher_hash}_{ext_hash}"This computation matches the FoxIO reference implementation. Run it on a captured ClientHello (use
scapyormitmproxyto capture) and you get the same JA4 a server would compute. Use it in tests to assert your scraper’s fingerprint is what you think it is.When JA3 still matters
Even though JA4 is the modern signal, JA3 still appears in older infrastructure. A few cases where JA3 is what your target uses:
- Self-hosted bot defenses built before 2024 (custom Nginx Lua modules, internal mitmproxy rules)
- Smaller bot-detection products that have not migrated yet
- Compliance and auditing systems that log JA3 by default for backwards compatibility
- Open-source projects like Suricata that still emit JA3 in alerts
For these cases, your TLS impersonation must produce a correct JA3 alongside a correct JA4. Both major libraries (curl_cffi, tls-client) emit consistent JA3s as a side effect of producing real-browser ClientHellos, so this is not an extra burden. Just verify both hashes after every library upgrade.
When JA3 mismatches but JA4 matches (and vice versa)
A subtle case: your scraper produces a randomized extension order (matches Chrome 110+ behavior), so the JA3 hash differs every connection while the JA4 stays stable. A vendor checking JA3 only might block you for “rotating fingerprints” while a vendor checking JA4 sees a stable Chrome client.
The reverse is also possible. You can produce a static extension order (matches old Chrome) that gives a stable JA3 in the allowlist but a JA4 that does not match Chrome 124. JA4 vendors flag this. JA3 vendors do not.
The fix in both cases is to align with what real Chrome 124 actually does: randomized extension order in transit, sorted-and-hashed JA4. Modern impersonation libraries do this by default with the right flag (
random_tls_extension_order=Truein tls-client, automatic in recent curl_cffi). Old configurations sometimes leave it disabled and produce one of the two failure modes above.For broader context on related fingerprinting techniques, see HTTP/2 fingerprinting and how to defeat it and header rotation and TLS profiles.
What to log so you can debug fingerprint drift
Add structured logging for every outbound request so you can correlate block rates against fingerprint changes. A minimal log line:
import json import time def log_request(url: str, ja3: str, ja4: str, status: int, latency_ms: int): print(json.dumps({ "ts": time.time(), "url": url, "ja3": ja3, "ja4": ja4, "status": status, "latency_ms": latency_ms, }))Pipe this to your log aggregator. When block rates spike, query for “JA4 distribution where status >= 400 in the last hour” and you will instantly see whether a single fingerprint is being targeted or whether it is broader. This is the difference between a five-minute fix (rotate to a new profile) and a five-day debugging session.
Vendor migration timeline
A short reference for when each vendor adopted JA4 as a primary signal:
vendor JA4 logged JA4 weighted in score JA4 as block rule Cloudflare Q3 2023 Q1 2024 Q3 2024 DataDome Q1 2024 Q3 2024 Q1 2025 Akamai Q4 2023 Q2 2024 Q4 2024 PerimeterX Q2 2024 Q4 2024 Q2 2025 Imperva Q3 2024 Q1 2025 Q3 2025 By the start of 2026 every major bot-detection vendor used JA4 in production rules. The handful of self-hosted or smaller setups still on JA3-only is a shrinking tail. If you optimize your stack for JA4 today, JA3 happens to also be correct as a side effect.
FAQ
Q: my scraper passes JA3 checks. Do I need to do anything for JA4?
Probably not, if your TLS library is recent. Verify with tls.peet.ws. If your JA3 matches Chrome 124 and your library is curl_cffi 0.7+ or tls-client 1.6+, your JA4 is almost certainly also Chrome 124. The migration is verification, not rewrite.Q: which is harder for vendors to compute, JA3 or JA4?
JA4 is slightly more expensive because of SHA-256 versus MD5, but both compute in microseconds. The cost is irrelevant compared to the rest of the request handling stack.Q: can I rotate JA4 between requests like I rotate User-Agent?
You can, but you should not unless you are also rotating other coupled signals. A single TCP connection has one JA4, but on the connection level you are bound. To rotate JA4, you need to open a new connection with a different impersonation profile. Most libraries support this via session pools, but make sure your User-Agent and HTTP/2 settings rotate together to avoid creating an obvious mismatch.Q: do mobile browsers have different JA4s than desktop?
Yes. Safari iOS produces a JA4 distinct from Safari macOS, and Chrome Android differs from Chrome desktop. Most impersonation libraries provide separate profiles (safari_ios_17,chrome_android_124). Use the matching profile for any User-Agent claiming mobile.Q: what about JA4S? Should I worry about it?
JA4S is the server fingerprint, not the client. As a scraper you do not produce JA4S, the server does. Some advanced scraping tools use JA4S to fingerprint the target server, but you do not need to defend against it.Common pitfalls in production
The first failure mode that catches teams off guard is the JA4_R variant, which is the “raw” form of JA4 that hashes ciphers and extensions in the order the client actually sent them rather than sorted. Cloudflare and Akamai compute both JA4 and JA4_R, and a mismatch between the two (your sorted hash matches Chrome 124 but your raw hash does not) is itself a flag. This happens when an impersonation library produces the right set of ciphers and extensions but ships them in a non-Chrome wire order. Curl_cffi 0.7.x had this bug for the Safari 17 profile through patch release 0.7.3, where the raw extension order matched curl’s internal default rather than Safari. Audit JA4_R alongside JA4 on tls.peet.ws under the
ja4_rkey.The second pitfall is HTTP/2 priority frame fingerprinting. Chrome ships PRIORITY frames after the initial HEADERS frame on every request, with a specific dependency tree (stream 0 with weight 256 for the main document, stream 13 with weight 220 for stylesheets, stream 11 with weight 147 for scripts). Most scraping libraries omit PRIORITY frames entirely. Akamai’s HTTP/2 fingerprint encodes this absence as a distinct hash component. The fix is non-trivial: you need a library like
h2with manual frame control or hyperframe-aware tooling, because high-level HTTP clients abstract this away. For most scrapers the practical answer is to use Playwright when targeting Akamai-heavy sites rather than fight the priority-frame issue directly.The third pitfall is connection reuse. Real browsers open a single TLS connection to a host and reuse it for dozens of requests via HTTP/2 multiplexing. Scrapers commonly open a new connection per request, producing dozens of identical JA4 handshakes per second from a single IP. The JA4 itself looks like Chrome, but the handshake rate looks nothing like Chrome. Configure your client with
keep_alive=Trueandmax_keepalive_connections >= 10(httpx) orSession()with explicit connection pooling, and verify withtcpdump -i any -n 'tcp port 443'that your scraper opens one TLS handshake per host per minute under normal load, not one per request.Real-world drift example: Cloudflare May 2026 update
In early May 2026 Cloudflare pushed a JA4 rule update that tightened the matcher on the extension hash component. Scrapers running curl_cffi 0.6.x with the chrome120 profile started receiving 403s on Cloudflare-protected APIs within four hours of the rollout. The JA4 string itself looked fine (
t13d1516h2_8daaf6152771_b186095e22b6reported by tls.peet.ws), but the actual extension hash differed because curl_cffi 0.6.x had been padding the signature_algorithms list with two trailing zero entries that newer Chrome stable removed. The fix was a one-line bump to curl_cffi 0.7.4 plus a profile change from chrome120 to chrome124. Teams that had pinned versions and ran nightly tls.peet.ws diff jobs caught it within an hour. Teams without monitoring discovered it via customer complaints two days later. The lesson: pin and monitor, do not pin and forget.Wrapping up
JA3 walked, JA4 ran. The transition was fast because the structural improvements were real, and any vendor that did not migrate by 2025 is now behind on detection accuracy. For scrapers, the practical impact is small because the same impersonation libraries handle both correctly. The work is in verification: every library upgrade, every Chrome stable release, every new target site, run a fingerprint check before assuming your stack is current. See the anti-detect-browsers category on DRT for related deep-dives, and pair this article with our TLS fingerprinting guide for the full context behind the hashes.
-
TLS fingerprinting in 2026: a complete guide for scrapers
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
requestscall shipping throughurllib3and 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,EllipticCurvePointFormatsFor 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,0That 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
requests2.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-2Hashed 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
requeststocurl_cffiand target Chrome 124, you replace your stdlib OpenSSL handshake with one that matches BoringSSL exactly.Bypass approach 1: curl_cffi for Python
curl_cffiis the most popular Python solution in 2026 because it leverages curl’s--impersonatemode, which itself uses a patched libcurl that produces ClientHellos matching specific browser versions. It is a drop-in forrequestswith 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 includechrome116,chrome120,chrome124,safari17,safari17_2_ios,firefox124, andedge124. 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 ofcurl_cffi.requests.Session(). Make sure every call goes through the curl_cffi import, not standardrequests.Bypass approach 2: tls-client (uTLS-backed)
tls-clientwraps 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=Truematches 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 oldertls-clientversions was itself becoming a flag.For high-volume operations, build a pool of
tls_client.Sessioninstances 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 htmlPlaywright 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
qprefix 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_cffion 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_b186095e22b6versust13d1714h2_5b57614c22b0_3d5424432f57), and the production target has since allowlisted only the Chrome 124 cipher hash. Pin the curl_cffi version inrequirements.txtand refresh deliberately rather than lettingpip install --upgradedrift 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. Setrandom_tls_extension_order=Trueand 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_cffiandtls-clientcover 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.