Skip to content

Bot detection

The edge has no bot score. It gives rules the facts a score would be built from, and leaves the decision to policy you can read, test and roll back:

Fact Field or setting From
Automation hints in the headers client.signals The request's headers and viewer fields (signals)
TLS fingerprint tls.client.ja4, tls.client.ja3 The CDN, through VIEWER_HEADERS
Crawler whose IP is in its operator's published crawler ranges client.verified_bot Ranges admin fetches from VERIFIED_BOT_SOURCES
Many different paths, IDs or IPs distinct rate limits Valkey (Distinct)
Many 404s, 401s or 403s count_statuses rate limits Each edge's memory (Counting responses)

A config rule's block setting answers 403; a rate limit answers 429. Every recipe below starts as log: watch Stats and the access log's client.signals and tls.client.ja4 until only the clients you mean to stop show up, then switch to block and publish through a canary. The rollback guard judges a rule that logged first against its logged share × max_vs_shadow (3×); a new rule with no log run rolls back if it blocks over max_ratio (5%) of requests. kill_switch.block turns every block setting back into log.

A signal or fingerprint is a hint, not proof. A careful bot sends a browser's headers and TLS stack, and some real users run privacy tools or old browsers. Keep crawlers you want out of every blocking rule with not client.verified_bot.

client.verified_bot needs VERIFIED_BOT_SOURCES; without it, validation refuses rules that read the field. Until admin's first fetch succeeds, the field is false. For Google, list only common-crawlers.json and special-crawlers.json. The user-triggered fetcher lists cover fetches other people control, such as Google Cloud customers' fetchers, so never use them for an exemption.

Refuse tools on pages

A config rule that blocks scripted clients on the site's pages, but not on its API, where tools are expected:

client.signals in {"ua_missing" "ua_tool" "ua_headless"} and not starts_with(http.request.uri.path, "/api/") and not client.verified_bot
{ "settings": { "block": "log" } }

Browser signals such as browser_no_sec_fetch fire for a client that claims a modern browser but leaves out what that browser always sends. They need trusted ssl, HTTP version or JA4 sources; validation refuses a rule naming a signal the deployment can't compute.

JSON answers on API paths

The default forbidden template is HTML. Add a template named forbidden-json with status 403 and content type application/problem+json on Templates, then map it for the API in a config rule:

starts_with(http.request.uri.path, "/api/")
{ "settings": { "responses": { "forbidden": "forbidden-json" } } }

Any blocking rule then answers API requests with it, unless a later matching rule maps forbidden to another template.

Limit by TLS fingerprint and network

A botnet rotating IPs often keeps one TLS stack and a few networks. A rate limit keyed on both catches it where a per-IP limit can't. ja4 is chosen by the client, so it needs an ip or JWT claim partner, or a site-wide rule keyed on ip:

[
  { "id": "per-ip", "algorithm": "sliding_window", "limit": 600, "period": "1m", "key": ["ip"],
    "on_missing_key": "fallback_ip", "action": "block", "fail_mode": "open" },
  { "id": "ja4-network", "algorithm": "sliding_window", "limit": 3000, "period": "1m", "key": ["ja4", "asnum"],
    "on_missing_key": "skip", "action": "log", "fail_mode": "open" }
]

per-ip has the expression true; ja4-network has not client.verified_bot. Many real users share a browser's JA4 and a large ISP's AS number, so set the limit from what Stats shows for the busiest legitimate pair. A request without a JA4, such as plain HTTP, skips the rule.

404 sweeps

A scanner probing for files gets 404 after 404:

{ "id": "404-sweep", "algorithm": "fixed_window", "limit": 30, "period": "1m", "key": ["ip"],
  "on_missing_key": "fallback_ip", "action": "log", "penalty": "10m", "fail_mode": "open",
  "count_statuses": ["404"] }

Failed logins

A password guesser gets 401 or 403 from the login endpoint. With the expression http.request.uri.path eq "/login" and http.request.method eq "POST":

{ "id": "login-failures", "algorithm": "fixed_window", "limit": 10, "period": "10m", "key": ["ip"],
  "on_missing_key": "fallback_ip", "action": "log", "penalty": "15m", "fail_mode": "open",
  "count_statuses": ["401", "403"] }

Crawl breadth

A crawler you didn't invite walks many pages slowly, under any request rate. Limit different paths per IP:

{ "id": "crawl-breadth", "algorithm": "distinct", "distinct_of": "path", "limit": 300, "period": "10m",
  "key": ["ip"], "on_missing_key": "fallback_ip", "action": "log", "fail_mode": "open" }

With the expression not client.verified_bot, verified crawlers are left alone.

An account used from many IPs

Shared or stolen credentials show up as one account from many addresses. On routes that require a token:

{ "id": "account-ips", "algorithm": "distinct", "distinct_of": "ip", "limit": 20, "period": "1h",
  "key": ["jwt_claim:sub"], "on_missing_key": "skip", "action": "log", "fail_mode": "open" }

A phone moving between networks uses a few IPs an hour, so start high.