How Cloudflare Bot Management detects scraping in 2026
Cloudflare published how most of this works. Developer docs, blog posts, patent filings. But nobody's assembled the full picture of what actually fires when Bot Management scores your traffic. Here's every detection signal, what it inspects, and how scraping teams clear each one.
Hire a Clawd is our own service.
Cloudflare publishes most of how their bot detection works. Developer docs, blog posts, patent filings. Nobody reads them (or they read the marketing summary and skip the mechanism underneath). So here's what Bot Management actually inspects, signal by signal, pulled from the public documentation and confirmed against what we see when scrapes fail.
The frame here is public data at a reasonable pace. Not credential theft, not hammering logins.
JA4 TLS fingerprinting
Before your scraper sends a single HTTP header, it's already been fingerprinted.
The TLS ClientHello that opens the connection has a structure: cipher suites, extensions, supported groups, signature algorithms, and the order they appear in. Cloudflare hashes this into a JA4 fingerprint. (JA3 is dead. Chrome killed it by randomizing extension order starting in late 2023, so Cloudflare and everyone else moved to JA4 and normalized variants that account for the randomization.)
JA4 is computed at the edge, from the handshake Cloudflare terminates. You can't spoof it with a header. You can't lie about it. The handshake is the handshake.
Here's the problem: Python's requests library uses urllib3, which
uses OpenSSL, which produces a TLS handshake that looks nothing like any browser's. Same story
for Go's net/http, Node's https module, and basically every HTTP
library that doesn't specifically impersonate a browser. Cloudflare knows what Chrome 126's
ClientHello looks like. They know what Firefox 128's looks like. And they know what "this is
obviously a script" looks like.
# Check your own JA4 fingerprint against a browser's
curl -s https://tls.peet.ws/api/all | python3 -c "
import sys, json
d = json.load(sys.stdin)
print('JA4:', d.get('tls', {}).get('ja4', 'N/A'))
print('JA3:', d.get('tls', {}).get('ja3_hash', 'N/A'))
"
Run that, then open the same URL in Chrome. Compare the output. If they don't match (and with
stock curl, they won't), Cloudflare has a signal against you before your first
byte of HTTP even arrives.
HTTP/2 frame fingerprinting
This catches people who fix JA4 but forget the layer above it. HTTP/2 connections open with a
SETTINGS frame, and the values in that frame differ between implementations. Chrome sends a
specific set of SETTINGS parameters in a specific order with specific values. Firefox sends
different ones. Python's httpx sends yet another set entirely.
Cloudflare also watches:
- WINDOW_UPDATE frame size
- Pseudo-header order (
:method,:authority,:scheme,:path; the ordering matters) - PRIORITY frames and stream dependencies
- HPACK header compression behavior
The combination creates an HTTP/2 fingerprint that's independent of JA4 but correlated with it.
A real Chrome browser produces a JA4 that says "Chrome" AND an HTTP/2 fingerprint that says
"Chrome." A scraper using curl_cffi with impersonate="chrome" gets
both right, which is most of why it works. A scraper using raw httpx gets neither.
Fingerprint-matching alone won't save a bad IP
Cloudflare scores IP reputation before the TLS handshake even matters. A rotating residential proxy pool with clean, non-flagged IPs is the base layer that makes everything above it work. Without it, a perfect JA4 still loses.
Canvas and WebGL fingerprinting
Once JavaScript runs (and on any Bot Management-protected page, it runs), Cloudflare's challenge script asks your browser to draw things and reads back the result.
Canvas fingerprinting renders specific text and shapes to a <canvas> element,
then calls toDataURL() to get the pixel data. Different GPU drivers, font
rendering engines, and anti-aliasing implementations produce slightly different output. This
isn't unique to you (millions of Chrome-on-Windows-with-Intel-UHD users produce the same hash),
but it's another signal in the stack.
WebGL goes deeper. It queries WEBGL_debug_renderer_info for the GPU vendor and
renderer strings, then runs shader operations that stress specific GPU features. The results
get hashed and scored against known populations.
The detection isn't "is this canvas hash known?" It's "does this canvas output match what this browser, on this OS, with this GPU should produce?" A headless Chrome on a Linux server with no GPU that claims to be Chrome on Windows with an NVIDIA card fails this hard. The canvas says one thing, the claimed environment says another, and Cloudflare scores the mismatch.
Navigator overrides and JavaScript detections
This is where most stealth plugins die.
Cloudflare's scripts probe the browser environment looking for contradictions. Some checks are well-documented, others we've reverse-engineered by watching what trips a challenge:
navigator.webdriveris the obvious one. Selenium, Playwright, and Puppeteer all set this totrueby default. You can override it, but the override itself is detectable (the property descriptor changes, or it shows up in the prototype chain).- Chrome DevTools Protocol (CDP) artifacts.
window.cdc_adoQpoasnfa76pfcZLmcfl_Arrayand similar injected properties are dead giveaways for Chromedriver. Cloudflare checks for these. navigator.pluginsandnavigator.mimeTypesbehave differently in headless mode. Real Chrome has a specific plugin set; headless Chrome historically had none or a truncated list.- Permission API inconsistencies. Query
navigator.permissions.query({name: "notifications"})in a real browser vs. headless. The responses differ. - Stack trace analysis. When Cloudflare's JS throws and catches an error,
the stack trace reveals whether code is running inside an
eval, aProxy, or a hooked function. Injected stealth scripts leave traces here.
// Simplified version of what Cloudflare probes (real script is obfuscated)
const isAutomated = (() => {
try {
const desc = Object.getOwnPropertyDescriptor(navigator, 'webdriver');
if (desc && desc.get) return true; // overridden getter = patched
if (navigator.webdriver === true) return true;
for (const key of Object.keys(window)) {
if (key.match(/^cdc_/)) return true; // Chromedriver artifact
}
return false;
} catch (e) {
return true; // errors here are suspicious on their own
}
})();
The real detection script changes frequently, but the pattern stays: probe, compare, score.
Inject-based stealth tools like the old playwright-stealth
patch these values from the outside, and
they can be caught doing it. A browser that spoofs at the binary level
(Camoufox, for example) is harder
to detect because the values read back as native, not patched.
A browser that doesn't need stealth patches
Anti-detect browsers spoof fingerprint values at the engine level, so there's no injected JavaScript for Cloudflare's probes to catch. If you're running scrapes against Bot Management targets, this is the layer that separates "blocked in 10 requests" from "running for weeks."
ML scoring and behavioral heuristics
This is where Cloudflare's documentation gets vague on purpose. Bot Management feeds all of the signals above (plus IP reputation, ASN, geolocation, request rate, mouse movement telemetry, and historical behavior across their entire network) into a machine learning model that outputs a bot score from 1 to 99. Site operators set their own thresholds: challenge anything below 30, hard-block anything below 10.
What we know from testing:
- The model scores sequences, not single requests. Your first request might pass fine. Requests 2 through 50, if they're identical in timing and pattern, drop the score steadily.
- Mouse and keyboard telemetry feeds it. No mouse events at all is a signal. Perfect straight-line mouse paths are a signal. Zero keyboard input on a page with a search box is a signal.
- Time-on-page matters. Loading a page and requesting the next one 200ms later, every time, is a pattern no human produces.
- Cross-site reputation. Cloudflare sees traffic across millions of domains. If your IP or fingerprint cluster is hitting 50 Cloudflare-protected sites in an hour, the score drops on all of them.
This is the hardest layer to beat because it's opaque and it adapts. You can't inspect the model. You can only observe the outputs and adjust. And the vendors like it that way.
Five layers of detection is a lot to keep alive
TLS fingerprints, HTTP/2 frames, canvas hashes, navigator probes, and a scoring model that shifts without warning. Hire a Clawd is a personal AI agent that manages the entire stack, rotates around new detections, and delivers clean data while you do something else.
See plans from $49/mo →How scrapers actually clear these hurdles
Five layers sounds like a lot, and it is. But they interact, which means you can pick the right tool for the specific detection surface you're hitting instead of throwing the heaviest option at everything.
Sites with passive fingerprinting only (no JS challenge)
Use curl_cffi with browser impersonation. It matches the JA4 and HTTP/2
fingerprint of a real browser, runs no JavaScript (so none of the JS checks apply), and
it's fast. Pair with residential proxies because datacenter IPs fail reputation checks before
fingerprinting ever matters. We cover picking a pool in
the residential proxy guide.
from curl_cffi import requests
resp = requests.get(
"https://example.com/api/data",
impersonate="chrome",
proxies={"https": "http://user:pass@resi-gate.example.com:8080"},
)
print(resp.status_code, resp.text[:200])
Sites with active JS challenges (Turnstile, managed challenge)
You need a real browser with source-level spoofing. Camoufox patches Firefox at the binary level, so canvas, WebGL, navigator properties, and plugin lists all read back as native values. The Firefox TLS fingerprint is also less scrutinized than Chromium's (Cloudflare built their detection primarily around Chrome, and they've historically been softer on Firefox).
from camoufox.sync_api import Camoufox
with Camoufox(
headless="virtual", # virtual display, trusted more than pure headless
geoip=True, # match tz/locale to proxy exit IP
humanize=True,
proxy={
"server": "http://resi-gate.example.com:8080",
"username": "user",
"password": "pass",
},
) as browser:
page = browser.new_page()
page.goto("https://example.com/products")
page.wait_for_load_state("networkidle")
cookies = page.context.cookies()
clearance = [c for c in cookies if c["name"] == "cf_clearance"]
print(f"Got clearance: {bool(clearance)}")
geoip=True is the detail people miss. It aligns the browser's timezone, locale,
and geolocation to the proxy's exit IP. A residential IP in São Paulo paired with a browser
reporting America/New_York is exactly the incoherence Cloudflare's model catches.
For a full walkthrough of the bypass side, see
the Cloudflare bypass guide.
The ML and behavioral layer
No single fix. You manage it.
- Randomize request timing with a distribution that resembles human browsing. Not
random.uniform(1, 3), which is too flat. Longer pauses between pages, shorter gaps for inline resources. - One browser session, one identity, one IP. Ten tabs on the same proxy in parallel reads as automated regardless of fingerprint quality.
- Rotate fingerprints across sessions, never within one. Changing your canvas hash mid-session is a signal, not an evasion.
The risk/reward math is straightforward. Every additional evasion layer costs compute,
bandwidth, or money. A site on Cloudflare's free tier doesn't need any of this;
curl_cffi with a decent proxy gets through. A site on Bot Management Enterprise
with custom rules might need every layer in the stack. Match the effort to the target. The
general bot-detection checklist
covers the same idea across all vendors, not just Cloudflare.
Skip the setup
The Anti-Detect Scraping Starter Kit
A ready-to-run Camoufox + residential proxy Python template with humanized timing, geo matching, and a pre-flight checklist for every detection layer on this page. Configured and tested so your first Bot Management scrape doesn't end on request one.
$19 one-time
Get the kit →FAQ
What's the difference between Cloudflare's free tier and Bot Management?
The free tier runs basic checks: IP reputation, a simple JavaScript challenge (the "checking
your browser" page), and WAF rules. Bot Management is the paid enterprise product that adds
JA4 fingerprinting, HTTP/2 analysis, ML scoring, and cross-network reputation. The free tier
is beatable with curl_cffi and a residential proxy. Bot Management usually
requires a real browser with source-level spoofing.
Does Cloudflare share detection signals across sites?
Yes. They process traffic for millions of domains and correlate behavior across them. If your fingerprint cluster hits dozens of Cloudflare-protected sites in a short window, your bot score drops everywhere. This is one reason a single shared proxy pool with heavy traffic degrades over time: other people's scrapes poison the IP reputation you're counting on.
Can I solve Turnstile with a CAPTCHA service instead of fixing fingerprints?
You can, but it's expensive at volume and it doesn't fix the underlying problem. Turnstile in managed mode often resolves invisibly when the rest of your stack is clean. Fix the IP, TLS, and browser layers first. Reserve CAPTCHA solvers for the challenges that still fire after everything else checks out. See the CAPTCHA services comparison for current pricing.
What JA4 hash should my scraper produce?
There isn't one "right" hash. JA4 varies by browser version, OS, and TLS library. The goal is
consistency: your JA4 should match the browser your User-Agent claims to be. If you claim
Chrome 126 on Windows, your JA4 needs to be Chrome 126 on Windows. curl_cffi and
Camoufox handle this automatically. If you're building a custom stack, compare your fingerprint
against tls.peet.ws using the same browser you're impersonating.