How Anti-Bots Work
On this page
- The Arms Race of Automation
- The 4 Layers of Bot Detection
- The Risk Score Model
- The Challenge Escalation Ladder
- The Goal of Evasion
- The Request Lifecycle, Hop by Hop
- What Each Hop Is Optimised For
- Scoring the Hops
- Static Rules, Behavioural Models, Reputation
- Where Escalation Happens, and What It Carries
- What the Score Does Not Contain
The Arms Race of Automation#
Modern web applications are constantly under siege by automated scrapers, scalper bots, and credential stuffers. To protect their infrastructure, companies employ sophisticated Web Application Firewalls (WAFs) and Bot Management Systems like Cloudflare, Datadome, Akamai, and PerimeterX.
These systems do not simply block IP addresses; they employ a complex, multi-layered approach to mathematically calculate the probability that you are a human. This probability is often expressed as a Risk Score.
The 4 Layers of Bot Detection#
1. IP Intelligence & Reputation Before you even complete a TLS handshake, the WAF checks your IP address against massive global databases. Is this IP from a known datacenter (AWS/DigitalOcean)? Is it a Tor exit node? Has this IP sent 50,000 requests to other Cloudflare-protected sites in the last 24 hours? If your IP reputation is poor, you are immediately blocked or served a hard CAPTCHA.
2. TLS & HTTP Fingerprinting
Bots rarely use real browsers. They use libraries like Python's requests or Golang's net/http. These libraries construct their TLS handshakes and HTTP/2 headers in a very specific, rigid order. WAFs generate a hash (like JA3 or the newer JA4) of these network signatures. If your signature screams "Python Script", your risk score skyrockets.
3. Execution Environment (JavaScript Challenges)
If you pass the network layer, the WAF may serve a lightweight, obfuscated JavaScript payload before giving you the actual HTML. This script probes your browser's environment:
- Does window.navigator exist?
- Are standard browser APIs (like Canvas or WebGL) rendering correctly?
- Are headless automation flags (like navigator.webdriver = true) exposed?
If your scraper cannot execute JS (e.g., BeautifulSoup), you fail instantly.
4. Behavioral Biometrics The final, most advanced layer. If you use a stealth headless browser to pass the first three layers, the WAF monitors how you interact with the page. - Do your mouse movements teleport instantly from coordinate to coordinate? - Is your typing speed perfectly consistent (150ms between every keystroke)? - Do you scroll the page in perfectly linear mathematical increments?
Real humans are erratic, slow, and messy. Bots are perfectly efficient. WAFs use machine learning models to detect this inhuman perfection.
The Risk Score Model#
In practice, the four layers are not passed or failed independently. Each signal contributes a weighted probability that is folded into a single risk score. A clean TLS fingerprint adds to your trust; a datacenter IP and zero mouse movement add to your suspicion. Only when the cumulative score crosses a threshold does the WAF act. This means a single weak signal can often be compensated for by every other signal being flawless—which is exactly why a consistent, complete browser stack beats one that is perfect at only one layer.
The Challenge Escalation Ladder#
Well-tuned bot systems respond proportionally. A mildly suspicious visitor might be silently served a lightweight challenge. A moderately suspicious visitor gets a full interstitial ("Checking your browser..."). A highly suspicious visitor gets a CAPTCHA or an outright block. Knowing which rung you are on tells you how aggressive you must be:
- Allowlisted (signed good-bot tokens, verified origins): no challenge at all.
- Soft challenge: invisible scripts that collect signals but rarely interrupt.
- Interstitial challenge: a visible "checking your browser" page that issues a clearance cookie.
- CAPTCHA: a hard human verification gate.
- Block: an instant 403 with no way around.
The Goal of Evasion#
Successful anti-bot evasion is not about "hacking" Cloudflare. It is about Blending In. Your goal is to make your automated traffic statistically indistinguishable from a bored teenager browsing on an iPhone. Every layer you perfect lowers the aggregate risk score, and a risk score below the threshold never triggers the escalation ladder at all.
The Request Lifecycle, Hop by Hop#
"Blocked by a bot manager" is a single verdict that eight separate systems had to agree on. When a challenge appears, ask which hop produced it, because the answer decides what you rebuild. The chain below is the order a request actually walks, and the signal budget each hop owns.
| hop | component | decides on | latency budget | what it can see | what it cannot |
|---|---|---|---|---|---|
| 1 | anycast edge, L4 | source address, connection rate | single digit ms | IP, ASN, TCP options, TLS record sizes | anything about the client software |
| 2 | TLS terminator | ClientHello shape, ALPN, ticket reuse | one RTT | JA4, cipher order, extension order, resumption | headers, JS, behaviour |
| 3 | HTTP/2 front end | SETTINGS frame, header order, stream concurrency | one RTT | SETTINGS hash, HPACK state, :authority |
anything inside the body |
| 4 | WAF rule engine | header grammar, cookie history, referer chain | under a millisecond | rules, allowlists, rate buckets | whether JavaScript ran |
| 5 | challenge orchestrator | whether to serve a JS challenge, and which flavour | tens of ms | the score from hops 1 to 4 | anything the page has not run yet |
| 6 | JS collector | ~200 browser API probes inside the page | 200 to 900 ms | navigator, canvas, WebGL, audio, fonts, permissions |
transport-level facts |
| 7 | application | account state, cart, order history | varies | your actual intent | everything synthetic |
| 8 | behavioural model | keystroke rhythm, pointer curvature, dwell distribution | minutes to hours | event sequences over a session | a single request |
Two properties of that table drive everything else in this course. First, hops 1 to 4 complete before any of your JavaScript exists, so no amount of page-side stealth can repair a bad JA4. Second, hops 6 and 8 need time: a challenge that reads twenty probes has to be given twenty probes' worth of a real browser lifetime, which is why a cold headless launch and a warmed profile land on different rungs of the ladder.
What Each Hop Is Optimised For#
The edge is the cheapest place to block, so vendors put most of their false-positive budget there. It sees a source address and a TLS handshake, which is why ASN and datacenter classification decides the fate of most of the traffic that never reaches an application. The TLS terminator is where JA3, JA4 and GREASE live, and it is the first hop that can tell a browser from a library with any confidence, because both claim to be "a TLS 1.3 client" and only one of them orders its extensions the way a browser does.
The HTTP/2 front end is the most under-appreciated hop. It compares SETTINGS frames, pseudo-header order and header casing against a database of real clients, and it can do so because HTTP/2 is binary and ordered in a way HTTP/1.1 was not. The JS collector is where the fingerprint surfaces get read, and it is the only hop that can see whether your canvas hash is stable across two loads. The behavioural model is the only hop with a memory: it remembers your last forty sessions, which is why risk score and session profile is a better mental model than "pass or fail".
Scoring the Hops#
Here is a working risk model, small enough to read in full. Eleven signals, weights between 0.10 and 0.31, five thresholds on the ladder, and five traffic shapes scored through it.
import textwrap
SIGNALS = [
# layer, key, weight, what that layer can actually observe on one request
("edge", "asn_datacenter", 0.30, "exit ASN is a cloud provider, not an access network"),
("edge", "ip_abuse_history", 0.22, "address on an abuse feed inside the last 7 days"),
("tls", "ja4_unseen", 0.26, "JA4 matches no browser the edge has ever seen"),
("tls", "no_resumption", 0.10, "repeat visitor opens a second full handshake"),
("http", "header_grammar", 0.14, "header order, casing and Sec-Fetch trio not browser-shaped"),
("http", "authority_mismatch", 0.19, "HTTP/2 :authority disagrees with the Host header"),
("js", "env_anomalies", 0.31, "plugin, permission and media probes answer non-browser"),
("js", "sensor_stale", 0.18, "sensor payload older than the nonce it answers"),
("behaviour", "rigidity", 0.24, "inter-key and inter-move variance under the human floor"),
("session", "coherence_break", -0.20, "IP country changed while one cookie session stayed live"),
("session", "trusted_tenure", -0.11, "cookie issued and then reused for twelve minutes"),
]
RUNGS = [(0.00, "allow", "no challenge, edge logs the visit"),
(0.18, "soft", "invisible probes, session risk accumulates"),
(0.34, "interstitial", "visible checking page, clearance cookie issued"),
(0.52, "captcha", "hard human gate, clearance costs a solve"),
(0.72, "block", "403 at the edge, no page to solve")]
TRAFFIC = {
"requests-default": [("asn_datacenter", 1.0), ("ip_abuse_history", 0.4),
("ja4_unseen", 1.0), ("no_resumption", 1.0),
("header_grammar", 1.0)],
"tls-impersonated": [("asn_datacenter", 1.0), ("ja4_unseen", 0.0),
("header_grammar", 0.6), ("authority_mismatch", 0.0),
("rigidity", 0.0)],
"headless-cold": [("ip_abuse_history", 0.0), ("ja4_unseen", 0.0),
("no_resumption", 1.0), ("env_anomalies", 1.0),
("sensor_stale", 0.6), ("rigidity", 1.0)],
"browser-wrong-geo": [("asn_datacenter", 0.7), ("ip_abuse_history", 0.2),
("coherence_break", 1.0), ("rigidity", 0.3)],
"warmed-profile": [("ip_abuse_history", 0.1), ("rigidity", 0.15),
("trusted_tenure", 1.0)],
}
def obs_to_dict(pairs):
return {k: v for k, v in pairs}
def score(obs, skip=None):
total = 0.0
for _layer, key, weight, _desc in SIGNALS:
if key != skip:
total += weight * obs.get(key, 0.0)
return max(0.0, min(1.0, total))
def rung(value):
label = RUNGS[0][1]
for threshold, name, _note in RUNGS:
if value >= threshold:
label = name
return label
def step_down(value):
lower = None
for threshold, name, _note in RUNGS:
if threshold < value - 1e-9 and (lower is None or threshold > lower[0]):
lower = (threshold, name)
if lower is None:
return RUNGS[0][1], 0.0
return lower[1], value - lower[0]
def main():
names = list(TRAFFIC)
width = max(len(n) for n in names)
print("one request, five stacks, same site")
print(f"{'stack':<{width}} score rung cost of one more step down")
for name in names:
value = score(obs_to_dict(TRAFFIC[name]))
lower, delta = step_down(value)
note = "no lower rung" if delta == 0.0 else f"{delta:0.3f} to reach {lower}"
print(f"{name:<{width}} {value:0.3f} {rung(value):<12} {note}")
print()
print("single-layer fixes against the stack that is one layer short")
obs = obs_to_dict(TRAFFIC["tls-impersonated"])
moves = sorted(((w * obs[k], k) for _l, k, w, _d in SIGNALS if obs.get(k)), reverse=True)
for delta, key in moves:
after = score(obs, skip=key)
print(f" remove {key:<20} -{delta:0.3f} -> {after:0.3f} {rung(after)}")
print()
print("how much of the ladder each layer can move on its own")
print(" mean fired weight per stack, against the weight that layer could expose")
for layer in ["edge", "tls", "http", "js", "behaviour", "session"]:
rows = [s for s in SIGNALS if s[0] == layer]
available = sum(w for _l, _k, w, _d in rows)
fired = sum(w * obs_to_dict(TRAFFIC[n]).get(k, 0.0) for n in names
for _l, k, w, _d in rows) / len(names)
share = fired / available if available else 0.0
print(f" {layer:<11} {fired:5.2f} fired of {available:4.2f} possible ({share:.0%})")
print()
print("three designs reading the same observation stream")
designs = [
("static rules", ["asn in cloud_set", "ja4 in known_bot_set",
"header_count < 8", "no_cookie_on_document"]),
("behavioural model", ["keystroke rhythm over 400 events",
"pointer curvature across 60 moves",
"navigation depth per cookie tenure",
"idle dwell distribution"]),
("reputation join", ["abuse feeds", "proxy-vendor egress lists",
"cohort risk from a 30-day aggregate",
"egress reuse rate across tenants"]),
]
for label, rules in designs:
print(f" {label}:")
for rule in rules:
print(textwrap.wrap(rule, width=66, initial_indent=" - ",
subsequent_indent=" ")[0])
main()
one request, five stacks, same site
stack score rung cost of one more step down
requests-default 0.888 block 0.168 to reach block
tls-impersonated 0.384 interstitial 0.044 to reach interstitial
headless-cold 0.758 block 0.038 to reach block
browser-wrong-geo 0.126 allow 0.126 to reach allow
warmed-profile 0.000 allow no lower rung
single-layer fixes against the stack that is one layer short
remove asn_datacenter -0.300 -> 0.084 allow
remove header_grammar -0.084 -> 0.300 soft
how much of the ladder each layer can move on its own
mean fired weight per stack, against the weight that layer could expose
edge 0.19 fired of 0.52 possible (37%)
tls 0.09 fired of 0.36 possible (26%)
http 0.04 fired of 0.33 possible (14%)
js 0.08 fired of 0.49 possible (17%)
behaviour 0.07 fired of 0.24 possible (29%)
session -0.06 fired of -0.31 possible (20%)
three designs reading the same observation stream
static rules:
- asn in cloud_set
- ja4 in known_bot_set
- header_count < 8
- no_cookie_on_document
behavioural model:
- keystroke rhythm over 400 events
- pointer curvature across 60 moves
- navigation depth per cookie tenure
- idle dwell distribution
reputation join:
- abuse feeds
- proxy-vendor egress lists
- cohort risk from a 30-day aggregate
- egress reuse rate across tenants
Three numbers in that output are worth more than the rest. tls-impersonated sits at 0.384, one interstitial rung, and 0.044 short of the next one down: a stack that has correctly impersonated Chrome at the TLS layer and still gets an interstitial is telling you the remaining 0.044 is not a TLS problem. browser-wrong-geo scores 0.126 and is allowed, because a coherent-coherence penalty of 0.20 is doing the work that a reputation bonus cannot: the negative signals are real, and they are large. And the last table shows the edge owning 37% of its own weight in this model, which is why IP reputation is the cheapest thing to fix and the first thing to check when a target suddenly starts challenging.
Static Rules, Behavioural Models, Reputation#
These three are not generations of the same technology; they fail differently, and knowing which one rejected you tells you what to change.
| static rules | behavioural model | reputation join | |
|---|---|---|---|
| latency | under 1 ms | 200 ms to 20 minutes | 20 to 500 ms |
| per-request verdict | yes | no, needs an event window | yes, once the tenant aggregate is warm |
| what it keys on | headers, ASN, JA4 list membership | event sequences over a session | address, tenant, ASN history |
| failure mode | false positives on unusual clients | misses deliberate slow automation | punishes a whole tenant for one member |
| evasion response | change the header set | change the event cadence | change the exit and the profile |
| blind spot | cannot see inside the page | cannot see the transport | sees no per-request detail |
A static rule is trivially defeated by reordering a list, and it is also trivially audited, which is why rule lists leak so reliably. A behavioural model is the hardest to forge because it needs a believable event history rather than a believable value; the cost is that it can only judge after the fact, which is why a first request through a clean exit still gets a challenge. The reputation join is the most blunt and the least escapable: abuse feeds and proxy-vendor egress lists are public, so a shared residential exit inherits the history of every other tenant who used it. See how a signal list is actually maintained for what sits on the other side of that join.
Where Escalation Happens, and What It Carries#
Escalation is stateful, which surprises people who expect a per-request decision. The soft challenge at hop 5 costs you nothing and buys the collector at hop 6 a few hundred milliseconds of your own browser's time. If that payload comes back clean, the orchestrator issues a clearance cookie and the score is written down against your address and profile. That write is the expensive part: it is what makes your next request cheap, and it is also what makes a changed next request suspicious. A clearance cookie presented from a different TLS session or a different exit reads as theft, which is the mechanism behind the session stickiness lesson.
So the ladder has a memory, and that memory is per-identity rather than per-request. The identity key is usually the pair (address, fingerprint hash) with cookies as the carrier. Break either half and you do not get a lower score; you get a new identity, and a new identity on an established address is exactly the pattern a behavioural biometrics model is built to catch.
What the Score Does Not Contain#
Three things that look like risk but never appear in a score, and their absence is why you keep getting challenged with an apparently perfect stack.
First, honesty. There is no signal for "this client is not lying", only for "this client is not lying about the things we check". A stack can hold navigator.webdriver at false, present a real GPU string, and reorder its extensions perfectly, and still score badly, because the model has no term for intent.
Second, per-layer cost. The hop table makes it look like fixing hop 6 is worth as much as fixing hop 1. It is not: an env_anomalies weight of 0.31 and an asn_datacenter weight of 0.30 are close in score terms but nowhere near in money. A residential exit costs cents per gigabyte and is instantly available; matching a canvas hash to a real device class needs a patched binary or a real machine and days of work.
Third, the target's own history with you. A site that has already decided you are a scraper does not rescore you from scratch; it remembers. That is why observing the ban, not the challenge is the only way to find out which rung you are on, and why the first task of any pipeline is to log the cookie timeline rather than the status codes.