First-party integration
Ad blockers and tracker-blocking browsers drop requests to known device-intelligence domains. That silently removes identification from exactly the traffic most worth identifying — and it fails quietly, so your dashboard just shows less traffic rather than an error.
Two ways to fix it. Both make the requests same-site, so cookies survive and there is nothing on the wire a content blocker recognises.
| Use when | |
|---|---|
| CDN proxy | You already run a CDN or edge worker in front of your site |
| Reverse proxies | What is in front is your own nginx, Traefik, Caddy or Envoy: the same edge, as a service it asks |
| Custom subdomain | You do not, and would rather point DNS here |
For Safari, the CDN proxy. WebKit's tracking prevention looks at where a request goes, not only at its name, and each release has gone further:
- Cookies set by a response from a subdomain of yours that is served from another company's addresses expire within seven days (Tracking Prevention in WebKit). A custom subdomain is such a subdomain; the CDN proxy answers from your own origin.
- Safari 26 keeps scripts it knows as fingerprinting scripts from reliably
reading 2D canvas, Web Audio, the screen's dimensions and the core count, and
from setting long-lived cookies or
localStorage(WebKit features in Safari 26.0). WebKit does not say how a script comes to be known as one; an agent served from your own origin, under a path you choose, is not one a list of domains can name. - Safari 27 is to block network access to "identity resolution infrastructure" by domain (WebKit, 19 October 2026). It does not say whether fraud prevention is counted as that; a request to your own origin has no other domain to block.
And Safari deletes what scripts write — the agent's
visitor hint among it — after seven days with no
interaction with your site. Behind the CDN proxy the edge also keeps the hint
as a cookie of your own (_abvh: HttpOnly, sent back only to its identify
mount), which a response from your own origin sets and Safari does not
delete, so the hint carries a browser across months there too; through a
custom subdomain, or with neither, across a week.
CDN proxy
Mount the agent and the ingest endpoint on your own origin — /_ab/agent and
/_ab/identify by default.
All routing, header, cookie and caching logic lives in one runtime-agnostic core; the platform files are thin adapters supplying the client IP.
| Platform | File | Run in the platform's own runtime here |
|---|---|---|
| Cloudflare Workers | proxy/src/cloudflare.ts |
Yes — workerd, the bundle wrangler deploy uploads (pnpm test:workerd) |
| Fastly Compute | proxy/src/fastly.ts |
Yes — compiled by Fastly's compiler, served by Viceroy (pnpm test:fastly) |
| Fastly (Deliver/VCL) | proxy/src/fastly.vcl |
Does not work as written — see below |
| Akamai EdgeWorkers | proxy/src/akamai.ts |
Does not run as written — see below |
| CloudFront Lambda@Edge | proxy/src/cloudfront.ts |
Built as deployed (esbuild, settings baked in), driven with CloudFront's origin-request events in Node (test/cloudfront.test.mjs) |
| Azure Functions | proxy/src/azure.ts |
Node, with @azure/functions' own request and response handling and its documented registration typechecked against the library's types (test/azure-functions.test.mjs); the Functions host itself has not been run |
| nginx, Traefik, Caddy, Envoy (reverse proxies) | proxy/src/forward-auth.ts |
Yes for nginx, Traefik and Caddy: the bundled service behind each real proxy (pnpm test:reverse-proxies). Envoy's configuration has not been run |
Akamai EdgeWorkers is not supported yet. The shared core is written against
the Fetch API — Request, Response, Headers, URL, crypto.subtle,
TextEncoder as globals — and EdgeWorkers has none of them: its HTTP is the
http-request and create-response modules, its crypto the crypto module,
its text encoding the encoding module. Checked against the EdgeWorkers type
declarations, the adapter cannot run there; it has never been run on Akamai, and
there is no local EdgeWorkers runtime to run it in. Until it is ported, put
Akamai in front of an adapter that does run — Azure Functions, Lambda@Edge, or
a reverse proxy with the forward-auth service as the
origin's first hop — rather than deploying akamai.ts.
Fastly's VCL is not supported either. fastly.vcl cannot share the core,
and it has not kept up with it: it routes two paths of the mount, not the rest
(the integrity key, the pulse, the challenge, the queue, the round trip); it
sends them to the API under the mount's own paths, which the API does not
serve; it keeps a cookie by a name the agent no longer sets; and it leaves in
place two of the headers the API believes once the proxy secret is on the
request (the forwarded host and the round trip), so a visitor could set them.
It has never been run on Fastly. Use the Compute adapter (fastly.ts), which
is the core and is run under Viceroy here — or put Fastly in front of an
adapter that does run.
Requests outside the mount prefix return null from the core and pass straight through, so a misconfigured route cannot take your site down.
Configure
| Variable | |
|---|---|
ANTIBOT_ORIGIN |
Your LayerSift origin |
ANTIBOT_ROUTE_PREFIX |
Mount path, default /_ab |
ANTIBOT_PROXY_SECRET |
See below. A secret, not a var. |
ANTIBOT_AGENT_MAX_AGE |
Seconds browsers and the CDN may keep the agent script it serves, default 3600. Keep it at an hour or less: a build stays accepted two hours after the deploy that replaced it, and a script kept longer than that, two deploys on, is refused (launching) |
Choose a prefix of your own. /_ab is where every installation starts, and
a filter list that names one path names every site that kept it: a visitor
whose blocker has learnt it runs no agent, carries no receipt, and is counted
among the pages asked for without one. A prefix that is yours — whatever reads
as part of your site — is one no list can name for everyone. Set it in
ANTIBOT_ROUTE_PREFIX and use the same in endpointPath below; the agent finds
its other routes beside it.
No list names one yet. Matched on 2026-10-29 against EasyList, EasyPrivacy,
uBlock Origin's filters and privacy lists and AdGuard's Base and Tracking
Protection lists, as an adblocker engine applies them, none of the agent's
requests is blocked — from /_ab, from a prefix of an
environment's own, or straight to api.antibot.dev — where Fingerprint's CDN
and API and DataDome's tag are. Nor is antibot.dev on Disconnect's list,
which Firefox blocks third-party fingerprinters by (Fingerprint's CDN is, as an
invasive fingerprinter; DataDome's and HUMAN's domains are under "Anti-fraud",
which Firefox lets through). A weekly run (web/scripts/filter-lists.mjs,
.github/workflows/filter-lists.yml) fails the day one is.
Then point the agent at your own origin:
Antibot.load({
apiKey: "pk_live_…",
endpoint: "https://example.com",
endpointPath: "/_ab/identify",
})
The agent also fetches a short-lived integrity key from /_ab/integrity
before identifying; the proxy routes it automatically, and the agent derives
the path from endpointPath, so no extra option is needed when both use the
defaults. The same is true of /_ab/challenge and the session pulse at
/_ab/pulse — beacons are same-origin like everything else, so blockers
have nothing to match, and the proxy's own routes are never subject to
enforcement (an enforcer may never refuse the traffic that earns tokens).
The proxy secret is the security crux
A proxy forwards the real client IP in antibot-proxy-client-ip. Headers are
attacker-controlled — anyone can set that with curl. If it were trusted
unconditionally, every IP-derived signal (geolocation, VPN, proxy, Tor,
blocklists, impossible travel, IP velocity) could be forged with one header.
So the forwarded IP is honoured only when the request also carries a valid per-organization proxy secret. Otherwise it is ignored entirely and the address falls back to what the platform in front of the app reports — which is only as trustworthy as that platform's handling of the same kind of header; see Which header to believe below.
Proxy → Generate secret (or, from the pipeline that deploys the worker,
POST /api/management/v1/proxy-secrets),
then set it on your worker:
wrangler secret put ANTIBOT_PROXY_SECRET
A wrong or missing secret degrades to unproxied identification rather than an error, so a stale edge deployment does not become an outage. Several secrets can be active at once, so you can roll a new one across edge nodes before revoking the old.
Which header to believe
The proxy secret covers requests that come through your proxy. Every other
request — a browser calling the API host directly, or a proxy you did not give
the secret to — has only the forwarding headers the platform in front of the app
reports, and a client can send every one of them: cf-connecting-ip,
true-client-ip, x-real-ip, x-vercel-forwarded-for, x-forwarded-for,
forwarded. Read whichever is present and the sender picks their own address —
a residential one, say — and walks past every IP-derived signal and every
per-address rate limit with one curl -H.
So the app believes exactly one header, the one your platform sets itself and overwrites if the client sent it, and nothing else:
| Behind | Set |
|---|---|
| Cloudflare | ANTIBOT_CLIENT_IP_HEADER=cf-connecting-ip |
| Vercel | nothing — detected (x-vercel-forwarded-for) |
| Fly.io | nothing — detected (fly-client-ip) |
| Cloudflare in front of Vercel or Fly | ANTIBOT_CLIENT_IP_HEADER=cf-connecting-ip — see below |
| A load balancer that appends | ANTIBOT_CLIENT_IP_HEADER=x-forwarded-for and ANTIBOT_TRUSTED_PROXY_HOPS=<how many of yours> |
Detection assumes the platform is the outermost edge. If another CDN sits in front of Vercel or Fly, the detected header holds that CDN's address for every visitor, so they would all look like one datacenter and share one set of per-address rate-limit buckets. Name the outermost edge's header explicitly; it beats detection. The app says which header it detected, once, in the log.
x-forwarded-for is read from the right: each proxy of yours appends the
address it saw, so the client is the entry hops places from the end, and
everything to its left is whatever the client claimed. A header that is not
x-forwarded-for or forwarded is single-valued, and if a duplicate was folded
into a list the last entry is the one used: an edge that appends rather than
replaces (an Envoy or HAProxy rule) puts the client's own value first. A
configured header that is absent, malformed, or shorter than the hop count means
"unknown address" — never a fall-back to a header that is easier to forge. A value
that is not an IP address is "unknown" rather than stored.
Two ways this goes quietly wrong are logged once each rather than left to be
discovered: a request that arrives with nothing usable in the configured header
(all such requests share one "unknown" rate-limit bucket, so a wrong header or hop
count throttles everyone together), and a header name that is not valid — a
quoted value from a Docker --env-file is unquoted, and anything still invalid is
ignored with an error rather than made a 500 on every request, which means the
address is then not being read from the header you meant.
The edge adapters apply the same rule to the address they send to the risk
call, report trap hits against and count sign-in failures for. Cloudflare,
Fastly and CloudFront take it from the platform itself. The Azure
Functions adapter has only headers: behind Front Door, set ANTIBOT_AZURE_FDID
to the profile's id, and X-Azure-ClientIP is believed on requests carrying a
matching X-Azure-FDID — and a request without it, which reached the function
around Front Door, is refused 403, as Front Door's own guidance does. The
profile id is not a secret, so do the rest of that guidance too: restrict the
function app's inbound traffic to the AzureFrontDoor.Backend service tag,
or a client that reaches it directly and knows the id names its own address. Without
Front Door it reads the last X-Forwarded-For entry, the one Azure's front end
appended, and ignores X-Azure-ClientIP entirely; behind Front Door without the
setting it reads no address (logging once that the setting is missing), since
that last entry would be Front Door's own, shared by every visitor. With no
address it can believe, the edge still makes the call, as an unknown address
(ip:unknown, weighed), and acts on the verdict, as every server SDK does —
never a pass on nothing at all. The adapter also
hands the browser what Node's fetch unpacked as unpacked: a compressed page's
Content-Encoding and Content-Length would otherwise describe bytes it no
longer has.
With nothing set (and no platform detected) the app keeps the old behaviour so an upgrade is not an outage, and logs a warning the first time it relies on it. Set the variable in production. The same address feeds the sign-in throttle and the session list, so those follow the same policy.
What else the secret unlocks
The same secret governs the transport evidence the edge forwards: JA4, ALPN, TLS version, the HTTP/2 prelude and header order. Without a valid secret these are ignored exactly as the client IP is, because a forged TLS fingerprint claiming to be Chrome would make an automated client look more trustworthy than an honest one.
With the proxy in place, products.network.available becomes true and the
network signal gains the TLS and HTTP/2 checks on
top of the header checks it performs everywhere. What each platform can see
differs, because the handshake terminates at the edge and every CDN exposes a
different slice of it:
| Platform | What the adapter captures |
|---|---|
| Cloudflare | JA4 + JA3 (plans with Bot Management), ALPN, TLS version and cipher, and the connection's round trip (every plan) |
| Fastly (VCL) | (not supported — see above) |
| Fastly (Compute) | JA4, JA3, the HTTP/2 prelude's fingerprint, TLS version and cipher (event.client) |
| Akamai | (not supported yet — see above) |
| CloudFront | JA4 + JA3 and the HTTP version (as ALPN) with ANTIBOT_CLOUDFRONT_TLS=on, TLS version and cipher — once the origin request policy forwards CloudFront-Viewer-JA4-Fingerprint, -JA3-Fingerprint, -Http-Version and -TLS (no charge) |
| Azure | Header order only — Front Door exposes no client TLS data |
ALPN alone separates a real browser from most HTTP clients, so even the thinnest row here is not nothing; the header-shape and header-order checks run on every deployment, proxied or not.
The browser's distance
Where the platform reports the connection's round trip — Cloudflare
(clientTcpRtt, or clientQuicRtt over HTTP/3) and the
forward-auth service behind nginx — the
proxy also times the browser itself, and the two disagree behind a proxy that
terminates TCP: a residential pool's exit acknowledges the edge's packets
itself, so the connection's round trip is the hop to the exit, and the browser
is further. Its answer to an identification carries the first token of a chain
(Antibot-Rtt); the agent, once the page is idle, sends four pings to
/_ab/rtt, each carrying the token the last answer sealed (an HMAC under a key
from ANTIBOT_PROXY_SECRET, bound to the visitor's address), and the proxy
answers each itself — never upstream — timing the wait from its answer to the
next ping, which a client can lengthen and never shorten. The chain's last
token rides the next session pulse; the proxy reads it and tells the API under
the proxy secret (antibot-proxy-rtt), and the pulse notes
rtt_beyond_connection (see the session pulse).
The tab keeps it (in sessionStorage, for four minutes, beside the ping path)
for its next page, whose identification carries it too, so the receipt that page
earns reads it: the proxy signal's relay_latency (see
smart signals).
Without a proxy secret, or on a platform that reports no round trip, no chain
is started and nothing is pinged; on Cloudflare ANTIBOT_RTT=off turns it off
(each ping is a request to the Worker: four per identification).
Cookies
The proxy strips every cookie except the agent's own (_abid, _abvh) before
forwarding. Making a request same-origin must not turn the proxy into a path
for your session cookies to reach a third party.
It sets one of its own: _abvh, the browser's visitor
hint, on the identify mount alone (Path=/_ab/identify
by default), HttpOnly, SameSite=Lax, for 400 days — what the API names in
its answer to an identification that carried the proxy secret, and nothing it
does not (rememberVisitor: false clears it). The agent sends it back because
it asks its own origin with the site's cookies: to any other origin it sends
none.
Edge enforcement
So far the proxy only observes. With enforcement configured it also acts:
requests to protected paths must carry a session token whose verdict passes,
or they are answered with a 403 at the edge and never reach your origin. This
is the PerimeterX Enforcer's role — the expensive analysis happened once at
identification, and every later request presents the signed receipt
(ab1.<claims>.<hmac> in the _abid cookie) for a local HMAC check. No call
home, sub-millisecond.
// wrangler.jsonc vars
"ANTIBOT_ORG_ID": "org_…",
"ANTIBOT_ENFORCE_PATHS": "/api,/account", // path prefixes to protect
"ANTIBOT_ENFORCE_MISSING": "challenge", // or "allow" (default) or "deny"
"ANTIBOT_ENFORCE_STEP_UP": "hold", // the challenge band held to a press-and-hold (unset: it passes); "hold-all": API calls too
"ANTIBOT_ENFORCE_MAX_SCORE": "70", // default 70, the High band
"ANTIBOT_ENFORCE_BLOCK_SIGNALS": "bot", // the default; signals in a receipt that condemn it, "none" for none
"ANTIBOT_ENFORCE_BIND_UA": "true", // "false" stops binding tokens to the client
"ANTIBOT_TOKEN_HEADER": "antibot-token", // the default; "none" reads the cookie only
"ANTIBOT_BOUND_PATHS": "/api/orders", // where only a receipt bound to the page's key counts (the agent's bindReceipt)
"ANTIBOT_ENFORCE_MODE": "monitor" // decide everything, act on nothing (unset: enforce)
A browser's CORS preflight — an OPTIONS with no body and no credentials (no
Cookie, no Authorization: a browser sends neither on one), naming its
Origin and the Access-Control-Request-Method it asks leave for — goes to
your origin unjudged, whatever ANTIBOT_ENFORCE_MISSING says: a browser sends no cookie or
header of the page's on one, so it can carry no receipt, and the request it asks
for is judged when it comes. Under deny a site whose pages call its API on
another origin (www. calling api.) could otherwise make no call to it but
the simple ones. Anyone can send such an OPTIONS, so your origin should answer
it as a preflight and never as a read: a route that serves a page's content for
any method would serve it there unjudged.
And a refusal of such a call is readable by the page that made it: to a call
its browser says came from the same site (Sec-Fetch-Site: same-site, which
no page can set), the edge's 403 allows that origin, with credentials, and
exposes antibot-enforcement and antibot-step-up — so the page's code reads
the refusal rather than a network error, and agent.fetch shows the hold a
step-up asks for. Another site's page is told nothing.
Monitor mode is how a site starts. With ANTIBOT_ENFORCE_MODE=monitor every
decision is made exactly as it would be — receipts checked, the risk call made,
sign-ins read and their outcomes reported — and none is acted on: a request that
would have been refused, challenged or queued goes on to your origin carrying
antibot-assess-would-act (block, challenge, queue) and
antibot-assess-would-reason (signal:bot, absent, assess:block,
crawler:aiTraining, probe:secrets, …) beside the usual antibot-assess-*
verdict. A crawler your policy refuses and a scanner's probe go on too, said so.
The risk call is told, so the refusals and challenges it would have made are kept
marked monitored — GET /api/management/v1/traffic/decisions/{reference} shows
which. A client's own antibot-assess-* headers are removed as always, so the
site can trust what it reads. Run it for a week, see what enforcing would do to
your real traffic, then unset it.
What monitor mode still does, and does not:
- A trap link followed, or a scanner's probe, still marks the address for your site, so its next requests' would-be verdicts are what enforcing would give them. It is not reported to the shared reputation network, and nor is a block the risk call would have made: other sites act on what the network hears, and a site still trying its settings has not stood behind them.
- A queue's admission handed over in
?abq=is still moved into its cookie (the one redirect monitor mode makes): it is the room's, not a refusal. - A crawler your policy would refuse is not counted among the edge's refusals,
since none was refused; the
would-headers are where it shows.
The receipt is read from the _abid cookie, or — for a client that has no cookie,
such as a native app's API calls (the mobile SDKs
hand the app its receipt) or a single-page app calling an API on another origin —
from the antibot-token request header. The cookie wins when both arrive. The
header is never forwarded to the risk call as one of the headers it judges.
wrangler secret put ANTIBOT_SESSION_KEYS # comma-separated, from the Management API
Fetch the keys with GET /api/management/v1/sessions/keys, with a key that
holds sessions:write: a session key signs receipts, so only a key that could
mint one is given its material (with sessions:read alone the list names the
keys without it).
A key that is not base64 is left out and said at start, by its place in the
list, never its value — read as a key, it once threw before the good ones were
tried, and any request with a cookie of three parts passed on unjudged. The
postures are read whatever their case (Deny is deny); one that is none of
their values is said and read as unset, and ANTIBOT_ENFORCE_MAX_SCORE must be a
score from 0 to 100. Enforcing without ANTIBOT_ASSESS_SECRET_KEY is said too:
without it the edge reads no revocation list, so a revoked receipt, and one of
your organization's other environments, passes until it lapses.
Every adapter that can pass a request on enforces, with the same settings.
Cloudflare reads them as Worker variables and secrets. Fastly Compute reads them
from two stores linked to the service: a Config Store linked as antibot holding
the settings by the same names (ANTIBOT_ORIGIN, ANTIBOT_ORG_ID,
ANTIBOT_ENFORCE_PATHS, …), and a Secret Store linked as antibot_secrets
holding ANTIBOT_SESSION_KEYS, ANTIBOT_PROXY_SECRET and
ANTIBOT_ASSESS_SECRET_KEY — a Compute service has no environment of its own to
set them in (fastly:env holds only what Fastly sets), and the Fastly adapter
used to read that, which found nothing and answered every request "not
configured". The backends are antibot_origin (this API) and origin (your
site). CloudFront Lambda@Edge has no environment variables, so they are baked
into the bundle as one object —
esbuild src/cloudfront.ts --bundle --platform=node --format=cjs --define:ANTIBOT_SETTINGS='{"ANTIBOT_ORIGIN":"…","ANTIBOT_ORG_ID":"…",…}'
— because a bundler's define replaces only names it can see, and the settings
are read by name: the per-variable --define:process.env.… these docs used to
show reached nothing, and enforcement stayed off. Attach the function to the
default (*) behaviour as well as the mount prefix's — not to behaviours
for the protected paths alone: CloudFront matches path patterns as written and
case-sensitively, so /LOGIN or /%6cogin takes the default behaviour,
never runs the function, and still reaches a sign-in handler that reads paths
the way the edge's canonicalPath does. The function decides what is
protected. A request the token
lets through continues to your origin exactly as before. It is an
origin-request trigger, and CloudFront runs those only when it goes to the
origin: a response it has cached is served without the function. So a
protected path must not be cached at CloudFront — give the behaviours that
carry them the managed CachingDisabled policy, or have the origin answer
them Cache-Control: private, no-store — or the page a person was let
through to is served to the next client, receipt or none. For the visitor's
TLS handshake, give those behaviours an origin request policy of your own that
names CloudFront's CloudFront-Viewer-JA4-Fingerprint,
CloudFront-Viewer-JA3-Fingerprint, CloudFront-Viewer-Http-Version and
CloudFront-Viewer-TLS headers (beside the viewer headers it forwards), and
bake in "ANTIBOT_CLOUDFRONT_TLS":"on": CloudFront writes those headers
itself only where a policy names them, so the setting is what says yours does —
without it the fingerprints are not read, since a client could have written
them. Azure needs one more setting, ANTIBOT_SITE_ORIGIN (your site's own
origin): with it, route the protected paths to the function as well as the mount
prefix, and requests it lets through are forwarded there; without it the function
serves the mount prefix only, as before, and does not enforce — it would have
nowhere to send what passes. Functions keeps one value per header name, so a
response that sets two or more cookies hands them to Functions as cookies of its
own, which it writes URL-encoded: a cookie whose value is letters, digits and
-._~ reaches the browser as the site set it, and a single cookie always does.
Enforcement switches on only when org id, keys and paths are all set — a partial configuration that silently enforced nothing would look exactly like protection.
The three postures, stated plainly. A condemned token — the rules engine
said block, the score is at the threshold, or the agent convicted the
browser of automation (bot in the receipt's signals) — is always refused;
that is the enforcement. The last is there because a conviction does not
always carry a high score: plain headless Chrome is convicted by its
User-Agent at 52, and its receipt, with no rule to say block, went through
on the score alone until it was read. A receipt whose browser said something
untrue about itself without being convicted of automation — tampering in its
signals: an anti-detect browser such as Camoufox (54, measured), desktop Chrome
wearing an iPhone's User-Agent (79, measured) — is let through by default, since a person's
own unusual setup can land there too; where that is not worth the risk — an
edge in front of nothing but a checkout or a sign-in — set
ANTIBOT_ENFORCE_BLOCK_SIGNALS to bot,tampering. A missing, expired or forged token is ambiguous, because
first-time visitors legitimately have none:
missing: "allow"(default) fails open: the page loads, the agent identifies, and from then on the verdict rides every request. Protect pages this way. A tokenless attacker passes, exactly like a first-time visitor — what enforcement adds on this posture is that anyone known bad stays out.missing: "challenge"runs the agent once before a browser's first page. A page navigation without a usable token is assessed, and unless that says block — or names a verified crawler or a vouched agent, which are let through — it is answered with the interstitial: it identifies the browser and reloads, and a person is through in the time an identification takes. This is the posture that stops a headless browser with a stealth layer: it sends Chrome's headers over Chrome's handshake, so nothing on the request tells it from a person andallowlets it straight in; the interstitial makes it run the agent, which sees what the layer forged, and the receipt it earns is refused on the reload. Measured with the real puppeteer-extra stealth plugin, automation flag removed: onallowit reached the site on its first request; onchallengeit was identified at 79 and refused, while a real Chrome from a clean address went through. API calls are still left to the risk call (nothing there can run an interstitial), and if the API cannot be reached the request goes on — the interstitial would have no identification to finish. The cost is one interstitial per browser without a fresh receipt, so pair it with the agent on your pages, whose receipts stay fresh as people browse.missing: "deny"fails closed: no token, no request. Use it for API routes your frontend only ever calls after an identification. Condemned and unknown clients are both refused.
The challenge band, stepped up at the edge. A receipt minted on a
challenge — a score in the 50s and 60s, or a rule's — passes the edge like any
other: what a challenge asks for has been the site's to show (onRiskChange,
solveChallenge). With ANTIBOT_ENFORCE_STEP_UP=hold the edge asks for it
itself. A page navigation to a protected path carrying such a receipt is
answered with a press-and-hold page — a 403, never cached or indexed, framed by
the site's own pages alone (X-Frame-Options: SAMEORIGIN, frame-ancestors 'self': another site's frame could lay a page over the button), in the
visitor's language. It identifies, the visitor holds the button while the agent
solves a proof of work, and the API checks both: the hold against its own clock,
events no hand made, a mouse press landing dead centre with no way to it. All
but the clock is the agent's own account of the hold: a client that writes the
numbers itself, instead of holding, passes for the price of the work — three
times a visitor in an hour (holdsTooOften after), and what its device runs up
after a pass still counts. A pass
mints a receipt that says a hold earned it (ch); the agent writes it into
_abid and the page reloads into what was asked for. Space or Enter held on the
focused button does the same for whoever uses no pointer, and a hold that did
not count is asked for again — but one made of events a script dispatched, or
a line it drew, ends the session it was asked on, and the device's next
identification carries it (holdNotAHand, in the
behaviour signal): failing the hold and
identifying again is no way round it.
The same page answers a visitor whose session the
session pulse
hardened to a challenge after the receipt was minted — a driver's clicks landing
where no pointer went, say. That receipt still says what identification thought,
so the edge holds the visitor's step-up from the list it fetches with the
revocations (below), until it sees a receipt a hold earned since. A visitor, not
a session: identifying again would otherwise leave the hardened session behind —
and identifying again meanwhile is itself a challenge, said why in the result
(decision.stepUp), so the receipt it mints is held too. Passing the hold takes
them off the list; left unpassed, a step-up lapses after thirty minutes. Support
sees it on the visitor's page in the dashboard — since when, and what hardened
the session — and can spare them the hold there, or through the
Management API.
API calls and form posts go on, as under missing: "challenge" — nothing there
can show a page — and a site that wants to act on them reads the receipt's
action as before. With ANTIBOT_ENFORCE_STEP_UP=hold-all an API call is
refused too, until the hold is passed: a 403 whose body says
StepUpRequired and whose antibot-step-up: hold header the page's agent
reads. agent.fetch(…) sends a request as fetch does, and on that answer
shows the hold over the page and sends it again once it passes
(stepUp) — so the receipt a
scraper earned in the challenge band stops working the site's API for its ten
minutes, and a person's single-page app holds once and carries on. A form a
browser posts is shown the hold, which it renders: Sec-Fetch-Mode: navigate
is the client's to say, so saying it earns the hold, never a pass. Passed, the
page reloads and the browser sends the form again — Chrome at once, Firefox
once the person agrees; WebKit asks for the address instead, and the form is
sent again from the page before it. Measured end to end with real Chrome: the call
refused, the hold over the page, a hand's hold, the same call answered. Where the edge also places the agent
(ANTIBOT_AGENT_INJECT=on), its tag says so (data-antibot-step-up), and the
agent shows the same hold over the page when a session hardens with no
navigation to meet it — a single-page app's
(stepUp). In monitor mode the page navigation goes on with
antibot-assess-would-act: challenge and antibot-assess-would-reason: stepUp:receipt (or stepUp:session). It needs the risk call's secret key and
ANTIBOT_PUBLIC_KEY, as the interstitial does; without them the setting does
nothing.
The agent on every page, placed by the edge. allow depends on the agent
being on your pages; ANTIBOT_AGENT_INJECT=on has the proxy put it there, with
no change to the site. Every 200 HTML page fetched with GET as a document that
it passes on gets one tag at the end of its <head> (at the end of the
<body>, or of the page, when there is none):
<script src="/_ab/agent" async data-antibot-key="pk_live_…"
data-antibot-identify="/_ab/identify" data-antibot-integrity="/_ab/integrity"
data-antibot-arrival="same-origin"></script>
data-antibot-arrival is how the page was asked for, its Sec-Fetch-Site,
which only the proxy saw and the page cannot write: same-origin for a page
reached by the site's own link, a form, a reload, back or forward; none for
one the browser went to on its own — an address typed, a bookmark, a link from
another application, a driver's goto. The agent sends it with the page's
first identification, and the API counts a device's arrivals: every page of ten
minutes reached the second way, eight or more and none by a link, is a list of
addresses being worked through (navigationsTyped, in the
behaviour signal).
The agent starts itself from those attributes, through the proxy's own mount,
so it stays first-party; a page that already loads it from the mount is left
alone. (A site that bundles the agent into its own JavaScript should leave
this off: the proxy cannot see it there, and each page would identify twice.) It needs
ANTIBOT_PUBLIC_KEY, and the proxy on the site's routes as for enforcement.
Only documents get it — a navigation or a frame (Sec-Fetch-Dest: document,
iframe or frame). What a page's own script fetches is left as it came, even
when it is HTML: plenty of endpoints answer text/html by default (a PHP
echo json_encode(...), a framework's plain string), and an htmx partial is
HTML on purpose, swapped in again and again. A client that sends no Fetch
Metadata is taken as asking for a document unless it says it is a script's
request (HX-Request, or X-Requested-With: XMLHttpRequest). An answer that
is no whole page — no doctype, <html>, <head> or <body> anywhere in it — is
a fragment and is left alone, and so is an AMP page (<html amp>, <html ⚡>
and the email and ads variants), which admits no script but AMP's own. So is a
page marked Cache-Control: no-transform, and, where the edge holds the page
whole (Azure, Fastly), one in UTF-16 or over 2 MB. The same reading decides where the trap
link goes when the edge places it.
Which platforms place it: Cloudflare rewrites the page as a stream with
HTMLRewriter; Azure holds it (up to 2 MB) and works on it byte for byte,
asking the site only for the encodings fetch unpacks (Chrome's zstd is left
out of the request, or the page would come back packed and go on without the
agent); Fastly asks the site for gzip, the one encoding Compute unpacks
(decompressGzip), places the tag, and hands the page back with
x-compress-hint: on for Fastly to pack again. CloudFront (Lambda@Edge never
sees the origin's body) and Akamai cannot: there ANTIBOT_AGENT_INJECT is
ignored with a warning in the logs, and the risk call is not told the agent is
on every page — load the agent from your own pages instead.
What it changes on the default posture: a stealth browser's first page is
still served — on the request it looks like a person — but that page runs the
agent, and the receipt it earns is refused on the second. Measured through
the real Azure adapter: a real Chrome from a clean address 200 -> 200, its
second page passed on its receipt with no risk call; the puppeteer-extra
stealth plugin 200 -> 403 score:79>=70. So allow with the agent injected
costs a person nothing and a stealth browser everything but its first page;
challenge takes that page too, for one interstitial per new browser.
It also tells the risk call that the agent is on every page, and that is what
catches a client that never runs one. An HTTP client that imitates Chrome on
the wire — curl-impersonate, curl_cffi: Chrome's handshake, HTTP/2 prelude and
headers in Chrome's order — looks like a person's first visit on every request,
and on allow each one would pass. But a person's second page carries the
receipt the agent wrote on their first. So pages a client asks for without one
are counted, per client — its address, User-Agent and languages — over ten
minutes, less the identifications that client's browsers made since — ones that
ran the agent, its program answered as a browser answers it: the third
is a reason (session:agentNotRun), the fifth a challenge, which a client that
runs no script cannot meet. Behind a carrier's address the client is many
people on one phone, browser and language, each a newcomer whose first page has
no receipt and whose agent then runs: each counts for no one but themselves. Measured through the real Azure
adapter: Chrome's navigation headers sent from Node, page after page —
200 200 200 200, then 403 assess:challenge; a real Chrome on the same
posture never noticed. Prefetches, API calls, verified crawlers and vouched
agents are not counted, nor are bots that say what they are and run no script
by design — Slackbot unfurling a link, an uptime monitor asking for the home
page every minute — and rules read the count as rate.pagesWithoutAgent. The
languages are read as a set of language tags, so a client re-weighting its
q= values on every request is still one client. A person behind the same
address with the same browser and languages shares the count (an office whose
first pages arrive together). The count takes the agent to be on every page: a
page the edge left alone (no-transform, too large to hold) runs no agent and
writes no receipt, so a site whose pages all go out no-transform should load
the agent itself rather than turn this on.
A page whose Content-Security-Policy admits scripts by nonce runs none without
it, so the placed tag carries the nonce: the one the response's
Content-Security-Policy header names for script-src-elem, script-src or
default-src — there before a byte of the page is, which a streaming rewrite
needs when the page's own nonced scripts all come after its head — and,
without one in the header (a policy set in a <meta> tag), the one the same
response gave the page's own scripts, read from the markup, where it is plain.
Measured in Chrome behind script-src 'nonce-…' 'strict-dynamic': the site's
script and the agent both ran and the receipt was written, at an ordinary score
— the policy also stops WebAssembly, which the agent does without. A policy that
lists hosts instead of nonces needs the proxy's mount allowed, as any script of
yours is.
Cloudflare places it with HTMLRewriter, as a stream. Azure, the one other
adapter that hands the site's answer back itself, holds the page whole to
place it — pages up to 2 MB, worked on byte for byte, so a page in any
ASCII-compatible charset keeps every byte it had. A page sent
Cache-Control: no-transform, one still compressed in an encoding the
function could not undo, or one in UTF-16 goes out as the site sent it; so
does any page if placing the tag fails. Its Content-Length and ETag are
dropped, since they described the page before. On Fastly, Akamai and
CloudFront the site's answer does not pass through the adapter: put the tag
in your pages' template.
The risk call: a verdict for requests with no token. Under "allow", a
token-less request passes unexamined — which is how a scraper that never runs
JavaScript walks through, request after request. Give the proxy a secret key and
it asks instead: for a protected request with no usable token (absent, expired,
forged, bound to another client), the edge sends the client's address, method,
URL, headers in the order they arrived and whatever TLS facts the platform
exposes to POST /api/v4/assess, and acts on the answer:
allow— the request continues to your origin, as before.block— 403 at the edge. The response carriesantibot-assess-id; the reasons are logged at the edge ([antibot] assess …) and never sent to the client, since telling a scraper which check it tripped tells it what to fix. A challenge is logged the same way; an allow is not — most of what is assessed, a line each was a busy site's log bill, and the dashboard's counts and your log streams have every one.challenge— a page navigation (aGETorHEADacceptingtext/html) gets an interstitial: a small page that loads the agent from this proxy's own mount, identifies, and reloads; the agent writes_abid, so the reload is judged by its token. A real browser is through in about the time an identification takes; a client that runs no JavaScript stays on it. It is a 403,no-store,noindexin both a header and the page, and framed by the site's own pages alone, as the block page is; and it cannot loop: a browser that refuses the cookie, or reloads twice in thirty seconds without passing, is shown a message instead. Anything that cannot run the agent — an API call, a form post, or no public key configured — gets403 {"error":{"code":"ChallengeRequired"}}. The challenge and block pages speak the visitor's language fromAccept-Language— English, Spanish, French, German, Portuguese (Brazil), Italian, Dutch, Polish, Swedish, Turkish, Russian, Ukrainian, Japanese, Korean, Chinese (simplified, or traditional forzh-TW,zh-HK,zh-MOandzh-Hant), Arabic, Hebrew and Persian (laid out right to left), Indonesian, Malay, Hindi, Bengali, Vietnamese, Thai, Czech, Slovak, Romanian, Bulgarian, Greek, Hungarian, Croatian, Slovenian, Catalan, Lithuanian, Latvian, Estonian, Danish, Finnish or Norwegian (Bokmål), the most preferred one supported, English otherwise — withContent-Languageset to match.
wrangler secret put ANTIBOT_ASSESS_SECRET_KEY # sk_live_…; switches the call on
Give the edge a key of its own, created with Allowed to: Assess requests
only (or "scopes": ["assess"] through the management API).
It can call /assess and report sign-in outcomes and nothing else — events,
visitors and related visitors answer it 403 KeyNotPermitted — so a CDN
configuration that leaks hands out no visitor data.
"ANTIBOT_PUBLIC_KEY": "pk_live_…", // the challenge page identifies with it
"ANTIBOT_ASSESS_TIMEOUT_MS": "250", // default 250, at most 2000
"ANTIBOT_ASSESS_ENDPOINT": "https://…" // only when the API is not ANTIBOT_ORIGIN
The same names on every adapter. What it costs:
one call, and only for token-less requests on protected paths — a request with a
passing token is still checked offline and never waits on it, and a condemned
token is still refused without it. Under "deny" it is not made at all: the
request is refused whatever it looks like, and an allow answer would only
reopen what you chose to close. It fails open. A timeout, a network error, a
non-200 or an answer that does not parse leaves the request to the posture as if
the call were not configured — a slow or unreachable API must never turn your
first visits into error pages. The time limit holds even on platforms whose
fetch ignores an abort signal. Never sent: the Cookie, Authorization and
Proxy-Authorization headers (your visitors' credentials are not evidence),
the proxy's own antibot-proxy-* headers, more than 64 headers, values past
2 KB or a URL past 2 KB. The secret key goes in the call's Authorization
header and nowhere else.
Sign-ins, with no application change. Name your login endpoints and the field each posts its account in, and the edge protects them against credential stuffing and account takeover by itself:
"ANTIBOT_LOGIN_FIELDS": "/login=email,/api/session=user.username"
Each pair is an exact path and a form field name — or a dotted path into a JSON
body (user.username). A POST to one is assessed with the account it names,
even when it carries a passing token: a browser that passed the agent once can
still work down a list of stolen credentials, and the server only sees that if it
sees every attempt. (A condemned token is still refused at the edge without a
call; any other request keeps the behaviour above.) A configured sign-in is
covered whether or not its path is in ANTIBOT_ENFORCE_PATHS, and a sign-in
outside them is never refused merely for having no token.
What is read, and what never leaves: the account field only, from a copy of the request — the body reaches your site byte for byte. Bodies past 32 KB are not read (the request proceeds unassessed by account). URL-encoded forms, JSON and multipart forms are understood; the account is trimmed and cut at 256 characters. The password and every other field are never kept, logged, put in a header or sent anywhere, and the account itself goes only to the assess and outcome calls — never to a log line or a header.
What the verdict does on a sign-in: block is a 403 as above. challenge is
not answered at the edge — a 403 to a form post would show a person behind
an office NAT an error where their login page should be — but passed to your
application with the verdict, so it can step up (a CAPTCHA, a second factor).
allow continues as usual.
Gift cards and messages, the same way. Two more kinds of form are read at the edge, one field each, on the same terms:
"ANTIBOT_CODE_FIELDS": "/gift-card/redeem=card,/api/vouchers=voucher.code",
"ANTIBOT_CONTENT_FIELDS": "/reviews=review.body,/contact=message"
A POST to a code path is assessed with the code (protecting gift
cards) — never the PIN beside it — and your site's answer
is reported as accepted or refused: antibot-code: success|failure from the
application if it sets one (taken off before the answer leaves the edge), or else
400, 404, 409, 410 and 422 are a refusal and a JSON 2xx an acceptance. A redirect
says nothing (after post-redirect-get a refusal and an acceptance both redirect),
nor does a 2xx page, a 401, 403 or 429 (a firewall, a rate limit, a challenge's
step-up) or a 500 — say it with the header if your site answers that way. A
field the edge cannot read — a body padded past the cap, a type it does not
parse, the field sent twice (PHP reads the last, most else the first) — is not
passed off as a plain request: the assessment carries form:unread:<kind> (20
for a sign-in or a code, the reason alone for a message, whose form may carry
an attachment). The query string is read for the field too, since Spring,
Rails and ASP.NET read it beside the body: there alone it is the field; there
twice, or there and in the body with another value, it is unread. Paths are
matched as the site reads them — percent-encoding, case, dot segments and path
parameters (/login;jsessionid=…) aside. A format suffix is not: Rails routes
/api/orders.json to the action /api/orders names, and to the edge it is
another path — protect the prefix both share (/api), or name each. A POST to a content path is assessed with the
message (protecting forms), read from bodies up to 96 KB and
cut at 20,000 characters; nothing is reported back, since whether a message was
spam is your moderators' to say. Both are covered whether or not their paths are
in ANTIBOT_ENFORCE_PATHS, and a challenge reaches your application as for a
sign-in.
Flows, step by step. The steps of a checkout, a sign-up or a booking, named at the edge, are read against each other with no change to your site (steps taken faster than a person):
"ANTIBOT_FLOWS": "checkout=POST /cart/checkout, /checkout/delivery, /checkout/payment@3000; signup=POST /join, POST /join/details"
Flows are separated by ;, each name= its steps in order; a step is
[METHOD ]path[@minMs] — POST unless said, a GET only as a page navigation,
the path exact and matched as your site reads it, @minMs the least time a
person takes from the step before (a second when not said). Name the request
that takes the step — the form a person submits to go on — not the page it
lands on: after a POST answered by a redirect, the next page arrives in
milliseconds, which no step is. A request that takes a step is assessed with
it, behind a passing receipt too — a receipt says a browser ran the agent
once, not how fast it went through the checkout — and a challenge there is
the press-and-hold, as a step-up is: over the page for a
form a browser posts (passed, the browser offers to send it again), a 403
with antibot-step-up: hold for an API call, which the page's agent.fetch
shows the hold for and sends again. A block is refused. Covered whether or
not the paths are in ANTIBOT_ENFORCE_PATHS (which must be set, as for
forms), and never sent to a waiting room off them. A step that does not
parse, a path with a query and a path named twice are said once in the log,
since none of them is ever matched.
GraphQL: one request that is many. Your GraphQL endpoints, named at the edge, are read for what each request would run:
"ANTIBOT_GRAPHQL": "/graphql, /api/graphql"
A request there is assessed with its documents (graphql):
a JSON body's (one, or a batch's, up to 256 KB) — or a text/plain one that
is JSON — a form's fields, an upload's operations (its first part, never
the files), an application/graphql body's, and the URL's query beside any
of them, which some servers read first — each with its string values
emptied, comments and spacing out, so a password written inline is never
sent on, and each distinct text once with the times it came. One mutation
many times in a request — sign-ins as aliases, codes in a batch — is read
past every limit that counts requests. A body there the edge cannot read —
compressed, past the cap, of another type — may hold anything, and is sent
as a document cut short, which the API counts as unread. A mutation, a
batch, or a document cut short is assessed behind a passing receipt too, and
a challenge there is the press-and-hold, as for a flow's step; a page's
queries behind a receipt go through as any request does. A persisted query
sends only a hash: it is read as its text when the edge has seen the two
together (an automatic persisted query's registration, learnt only where the
hash is the SHA-256 of the text, as Apollo's server checks — a client cannot
make another operation's hash read as a harmless query) or when
ANTIBOT_GRAPHQL_PERSISTED names it — your trusted documents, a JSON object
of each id to its text, the only way a document id is read — and otherwise
counted, with the most times one came. Covered whether or not the paths are in ANTIBOT_ENFORCE_PATHS (which
must be set, as for forms).
Page scripts. The agent's list of a watched page's scripts
(Page scripts) goes to /_ab/scripts (scriptsPath),
forwarded as it came. With ANTIBOT_SCRIPT_PAGES (/checkout/*, /pay) the
edge also sets the browser's own report up, with no change to the site: each
watched page's HTML answer gets a report-only policy beside any the site sends
— script-src 'none'; report-uri /_ab/csp; report-to antibot-scripts, and the
Reporting-Endpoints header that names it — which blocks nothing and reports
every script the page runs to /_ab/csp (cspPath), first-party; the edge
forwards each report to the API under ANTIBOT_PUBLIC_KEY, with the client's
address under the proxy secret.
A signed-in session. ANTIBOT_SESSION_COOKIE names your own session
cookie (ANTIBOT_SESSION_COOKIE=app_session). Every assessment then carries
it as userSession — a SHA-256 digest, never the cookie —
and the API binds it to the device and the browser that first used it: the
same session from another device, or another browser or system, is a cookie
lifted from its owner and replayed elsewhere (userSession:otherDevice,
userSession:otherClient). A User-Agent that names no browser or system is
compared with none — your own server passing the cookie on names none — and a
script's (curl, python-requests) is a signal of its own,
userAgent:httpLibrary. Behind a passing receipt on a protected path,
each new pair of session and device is assessed once — a session's first
request here, or the same session from another device — and a challenge
there is the press-and-hold; a pair let through is not asked about again on
that instance for ten minutes, and every other request goes through as
before. Whoever holds a stolen session passes the hold as its owner does, so
the answer that matters is your own: a rule such as Ask again when a
session moves to another device flags it, and your site asks for the
password again before the actions that matter. Name a cookie whose value
stays for the session's life — a session id (PHPSESSID, JSESSIONID,
connect.sid, Django's sessionid) — never one written anew on every
response, such as Laravel's encrypted session cookie: each value would be a
new session, nothing bound, and every request behind a receipt assessed.
(Laravel binds its sessions in the middleware: ANTIBOT_SESSIONS=true.)
The verdict, for your site. Every request the edge forwards after an assessment carries it as request headers:
| Header | |
|---|---|
antibot-assess-action |
allow or challenge (a block never reaches you) |
antibot-assess-score |
0–100 |
antibot-assess-reasons |
comma-separated reason codes, cut at a whole reason before 1 KB |
antibot-assess-id |
the assessment's id |
The edge removes any antibot-assess-* header the client sent itself, on every
request it forwards, assessed or not — spelled with underscores too, since CGI
and FastCGI give antibot_assess_action the verdict's own name
(HTTP_ANTIBOT_ASSESS_ACTION) — so trust these headers only on requests
that came through the edge; an origin reachable directly must not read them.
Outcomes. After your site answers a sign-in whose account was read, the edge
reports how it went to POST /api/v4/assess/outcome, so failure ratios and
attempts per account count toward the next verdict. Your application can say so
itself with a response header, antibot-login: success or antibot-login: failure, which the edge takes off before the response reaches the visitor;
without it, only what frameworks agree on is read — 401 and 422 are a failure; a
redirect back to the sign-in form (Laravel, Rails) a failure and anywhere else a
success; a JSON 2xx a success. A 2xx page says nothing, since WordPress and
Django answer a wrong password with the form again, 200; nor do 403 and 429,
which may be a firewall, a rate limit or the step-up a challenge asked for; nor a
404 or a 500. After a challenge nothing is read from the status at all — the
answer may be the step-up's — only what the application declares. An outcome
read from a status is sent marked inferred: it counts toward failure ratios,
and a success read that way is never remembered as a device or a country the
account signs in from (a wrong password's 200 must not let an attacker write
their own device into the account's history). The report runs after the visitor has the response (through the
platform's waitUntil on Cloudflare and Fastly), and its failing never changes
the response.
Where each platform stands: Cloudflare, Fastly and Azure do all of the above.
CloudFront forwards the verdict headers and strips forged ones, but an
origin-request trigger never sees the origin's answer, so outcomes are not
reported from it; your application can report them itself to
/api/v4/assess/outcome. Akamai is not supported yet (see the adapter table).
A receipt belongs to the client that earned it. The token carries a keyed
tag (ub) of the User-Agent the identification arrived with, and the edge
compares it with the presenting request's own. A token carried to a different
client — the way a farm spreads one solved identification across many scripts
— counts as missing (binding:ua): refused under deny, and under allow
treated like a first-time visitor, so the page loads and the agent re-identifies
instead of locking out someone whose UA changed under a live cookie ("request
desktop site"). A condemned token is refused whatever it is presented with,
so changing the User-Agent never lifts a block. Be clear about the strength: a
script that copies the User-Agent string passes this check. What it removes is
the lazy case (a harvested cookie replayed from a default HTTP client) and it
makes every farm client pin a UA per token; it is not an IP or device binding.
Tokens minted before the claim existed carry none and are not checked. A client
that sends no User-Agent at identification is bound to having none, so
stripping the header does not earn a token every client may present.
And to the handshake it was minted on. A User-Agent is a string the script
copies with the cookie; the TLS handshake is not — requests, httpx and
aiohttp offer a handful of ciphers where every Chrome offers fifteen or more.
When the identification passed through an edge that reports the handshake
(JA4: Cloudflare with Bot Management, Fastly Compute), the receipt records
whether it fit the browser its User-Agent claims (tf). Presented later on a
handshake outside that browser's envelope, a receipt minted on one inside it
counts as missing (binding:transport), and what happens next is the
posture's: the risk call, sent the receipt with the handshake, does not take it
for a session either (session:transport) and judges the handshake (condemning
it, ja4ContradictsUa); the challenge asks for a browser. The same browser on
HTTP/3 is not read against the envelope, which describes the TCP handshake, and
on a WebSocket upgrade HTTP/1.1 alone is the browser's offer: that is how it
opens a socket's own connection. Every browser on an iPhone or iPad — Chrome, Firefox and Edge
there included, an iPad asking for the desktop site too — is Apple's TLS stack
and is held to Safari's envelope, by the receipt and the network signal alike. A
receipt minted behind a TLS-inspecting middlebox — every request its user
makes looks like that — is marked so and never checked, and a receipt minted
where no edge saw the handshake carries no tf. What still passes is a client
that imitates a browser's handshake (curl-impersonate, curl_cffi); against
that, the HTTP/2 prelude and the receipt's ten minutes are what is left.
When to turn it off. Set ANTIBOT_ENFORCE_BIND_UA to false if something
other than the identifying browser presents the _abid cookie to an enforced
path: a server-side renderer or backend-for-frontend forwarding it with its own
User-Agent, or a native app whose HTTP client sends a different one. Under
missing: "deny" those requests are refused as binding:ua. The switch is
global; the alternative is to leave such routes out of ANTIBOT_ENFORCE_PATHS.
The agent writes the _abid cookie itself after every successful
identification or challenge solve (disable with sessionCookie: false), so
no backend work is needed for the cookie to exist. Token TTL is ten minutes;
the cookie dies with it, and the agent's re-identification renews both.
Two honest limits. The edge checks the receipt offline, so mid-session
revocation is not visible to it directly — the ten-minute TTL bounds the
staleness, origin-side enforcement of the revocation list (Management API,
/sessions) remains the answer for tighter cutoffs, and the session
pulse closes most
of the window on its own: when continuous scoring condemns a session the
agent is told on its next beacon and clears _abid itself, which a
deny-posture edge then refuses. And a proxy fault fails open: availability
outranks enforcement, because a proxy bug must never be able to take the
site down.
Key rotation: POST /api/management/v1/sessions/keys mints a second key,
deploy both to the worker (comma-separated — any active key verifies), then
DELETE the old one, and once the receipts it signed have expired (ten
minutes), deploy the worker again with the new key alone. Nothing in flight
is stranded. The last step is the one that matters when a key has leaked:
the worker verifies with the keys it was deployed with, not the ones the
service still holds, so a key deleted from the service but left in the
worker still lets through any receipt someone signs with it. After a leak,
take it out of the worker at once — the receipts it signed for real visitors
are refused, and those visitors are identified again.
API calls only your pages can make
A receipt in the _abid cookie is a bearer token: a solver's browser that
earned it can hand it to an HTTP client that copies the browser — User-Agent,
headers, a Chrome TLS handshake (curl_cffi) — and every check above passes
for the ten minutes it lasts. Where only your pages call a path, set it in
ANTIBOT_BOUND_PATHS (path prefixes, within ANTIBOT_ENFORCE_PATHS) and load
the agent with bindReceipt: true
(browser agent). Each
identification then also answers with a receipt bound to a key the page holds
and cannot hand on (WebCrypto, not extractable), and the page's calls carry it
in antibot-token with a fresh signature by that key in antibot-pop —
t=<unix ms>,s=<base64url r||s>, ECDSA P-256 over <t>\n<METHOD>\n<path and query>, good two minutes, the form the mobile SDKs
already send.
On those paths the edge reads only that header: the cookie is not read, a
receipt bound to no key is no receipt (binding:unbound), and one without a
fresh signature by its key is none either (binding:pop:missing,
binding:pop:invalid, binding:pop:stale). Without one the call is refused,
403, whatever ANTIBOT_ENFORCE_MISSING says — the risk call would read a
client copying a browser, the caller the path is there to stop, as fine — and
the reason in antibot-enforcement tells the page's agent to renew its
receipt and send the call again. Measured end to end in
web/probes/edge.mjs 13e: the page's call through the agent passes; the
cookie in a client copying the browser, the bound receipt copied without the
key, and one signed by another key are refused. With no edge in front, the
server SDKs' guards do the same on their boundPaths
(server SDKs).
What binding stops is a receipt carried away from the browser that earned it — a solved challenge resold, a cookie copied into a script. It does not make an identification harder to earn: a client that runs the identification itself is given a bound receipt for a key of its own, judged on what it showed, as it would be given the cookie.
What a receipt is used for
A receipt lets a request through without asking anyone. That is the point of
a receipt, but it also meant that nothing saw what a receipt was used for in
the ten minutes it lasts. The edge now counts what each receipt let through,
by address, and reports it every ten seconds, behind the answer
(/api/v4/assess/receipts). Three uses that no person makes end the session
behind the receipt:
- A receipt shared round a pool. A person's receipt is used from one network, or two when a phone leaves the Wi-Fi. A receipt harvested from a browser and handed to workers behind a proxy pool is used from many. Five networks within its life revoke that receipt for an hour. A network here is a /24, or an IPv6 /48, so a carrier moving one person around its block counts as one.
- A device at a machine's pace. 1,200 requests to protected paths in a minute, twenty a second without pause, revoke the device for fifteen minutes.
- A receipt walking pages it never runs. Where the edge places the agent on
every page (
ANTIBOT_AGENT_INJECT=on), it also counts which of a receipt's requests were such pages. A browser runs every page it is given, and the agent on it speaks — it identifies, minting the receipt the next page rides, or pulses for the receipt's session. A client that only fetches the pages says nothing: a browser's cookie and User-Agent carried into an HTTP library that copies Chrome's headers and handshake, which is how FlareSolverr and its like hand a solved challenge on. Each page a browser runs answers for itself: it identifies, or — answered from its agent's cache — pulses for the receipt's session as a tab of its own. Eight more pages under one receipt than the tabs heard for its session and the identifications from its device since it was first seen revoke the receipt for an hour (receipt:pagesNotRun). A solver's browser that waits on the page it earned the receipt on is one tab, however often it pulses, and each identification it makes after is one page: neither answers for the pages a client fetches with the cookie afterwards. A beacon counts only from a page the identification's answer reached — each proves the session's pulse key, which the cookie never carries — and from the client the receipt was minted for, itsUser-Agentand through the edge its handshake: the library fetching the pages with the cookie cannot answer for them by pulsing. The eighth page sends the edge's batch at once, and the answer names what it got revoked, so the ninth is refused — measured end to end inweb/probes/edge.mjs: eight pages served, then the challenge.
The edge also holds the environment's revocations,
whether an operator wrote them or the counts above did. It fetches them
behind the requests it answers, at most every fifteen seconds — a request
never waits for the list once an instance holds one, so on an instance idle
for longer, the first request after the gap is judged on the list it had —
and holds at once whatever its own report got revoked, and reads a revoked
receipt as no receipt: the posture
then decides, and the assess call scores it as session:revoked, a challenge
on its own. A request does not wait for the list, and a failed fetch
keeps the list held — save on an instance that has never held one, a fresh
isolate, whose first receipts wait for its first fetch up to 300 ms, once:
otherwise they would be judged with nothing revoked, no environment and no
attack mode, and an edge of short-lived isolates starts cold often. The same fetch brings the visitors stepped up to a hold
(stepUps, above); they are not revoked, and nothing else refuses them. It
also says which environment the key speaks for: a receipt minted for another
of the organization's environments — they share session keys — is read as no
receipt, so a staging receipt cannot walk past production's rules and attack
mode; and until when the environment is under attack mode.
Identification blocks a revoked device, as it always has, so the device cannot
simply mint a new receipt.
This runs wherever the edge does: Cloudflare, Fastly, Azure, CloudFront and
the forward-auth service. It also runs in every server
middleware, which verify receipts themselves — Node and its Fetch-API guard,
Python, Go, .NET, Java, PHP and Ruby — the pages a receipt walks too, where the
site says its pages load the agent (agentOnPages). A list of revocations
fetched before an answer named a new one comes back without it; what the
answer named is kept until a fetch that set out after it. PHP holds all of it
per process, or across the pool's workers with APCu.
Attack mode
When an attack gets past the scoring — a new tool, a fresh proxy pool — switch
the environment to attack mode under Edge traffic: for fifteen minutes,
an hour, six hours or a day, every request assessed without a valid receipt is
challenged, whatever it looks like (attackMode among its reasons, and
"attackMode": true in the answer). A browser passes the challenge once and
carries a receipt from then on, which the edge checks without asking; a script
does not. It ends by itself, so a switch thrown in a hurry is never left on.
Identifications are held to it too. A receipt is what an identification
earns, so under attack mode one has to bring a browser's answer to its
integrity key's program: a payload
without — unsigned, signed by a build no one knows, its program unrun, unpaid or
answered as no browser does — is decided challenge at least
("attackMode": "unproven" in its decision), and the receipt it earns is held
to a press-and-hold. The edge and the server middlewares learn that the
environment is under attack mode from the list they fetch with the revocations
(below), and while it is, a receipt minted on a challenge is held as under
ANTIBOT_ENFORCE_STEP_UP=hold-all, whatever that is set to: its pages are the
hold, and anything else is refused until the hold is passed. Without a public
key to show the hold with, it goes to the risk call, which under attack mode
does not take such a receipt for one (session:stepUp) and challenges it. The
served agent in a browser runs its program and is left alone;
web/probes/attack-proof.mjs measures both. A phone is held to its attestation
the same way, where the environment can check one
(mobile SDKs).
What it leaves alone: requests with a valid receipt; verified crawlers and the
AI agents their operators vouch for, which cannot pass a challenge and are who
they say; and your own allow rules, so a partner's integration keeps working.
What it covers is what the edge assesses — the paths in ANTIBOT_ENFORCE_PATHS
and your configured forms — and anything your servers assess. An API client
that never runs the agent is challenged too, which is the point and the cost:
let the ones you know through with a rule.
It can also switch itself on: with Do that under the switch (or
"attackModeAuto": true through the Management
API), an attack alert opening throws it on for an hour —
where what was refused came from five clients or more (an address, an IPv6
client's /64): one client refused again and again is its own score's to answer,
and is alerted on without everyone being challenged for it — and so does a wave
of pages no browser ran. Where the edge places the agent
on every page, a person's first page is followed within seconds by its
identification, from the same address and browser. A client that never runs the
agent is challenged from its fifth page, per address — but a scraper behind a
rotating residential pool is a new address on every request, and every page of
it looks like someone's first. Summed over the environment it does not: a
hundred first pages or more in five minutes, at most two in five of them run,
where the hour before ran as people's do, open an unrun alert, and attack mode
then challenges every page without a receipt, which is every page of the wave.
A person answers that once. Each first page run counts once: an office behind
one address, every browser alike in version and language, is as many pages
run as people. The counts are each server instance's own, so behind a load
balancer the hundred is of the share that instance serves. Measured in web/probes/edge.mjs: a hundred and ten
pages from as many household addresses, the alert, attack mode, and the next
page without a receipt challenged.
Challenges that only attack mode made are not counted toward the alerts, so it
never holds open the alert that threw it. Every change, by a person or by the
alert, is in the audit log. Rules see it as attack.on.
When someone is turned away by mistake
Every block and challenge page that came from an assessment ends with a
reference — "If this is a mistake, contact the site and quote this reference:
asr_…", in the visitor's language — and an API client finds the same in
error.reference. Enter it under Edge traffic → Refusals to see when, where,
what was decided and every reason, and let that visitor through with one click.
Where the refused request carried a valid receipt, Let this device through
allows that device alone (session.visitorId == "…" and now < …). Prefer it:
an address is shared more often than it looks (a carrier's NAT, an office),
and an allow lets through everything that comes from it. Otherwise, or as
well, let the address through: its /64 for IPv6, where a phone changes address
within the /64 on its own (network == "…" and now < …). Either is an
assessment rule at the top priority for seven days; delete it on the Rules
page to undo sooner. The Management
API finds refusals too. Only refusals are kept, for seven
days, and a path segment that looks like a token, an email or a long number is
kept as :token, :email or :number.
Decoys instead of refusals
A 403 tells whoever runs a bot the moment it was caught — and so exactly when
to change proxies, headers or pace. With ANTIBOT_BLOCK_RESPONSE=decoy, a
blocked page navigation is answered 200 with a page that looks like any
other: no status, no antibot-* header and no reference say it was refused,
and its links lead into the trap (an empty page when the trap is
off). The refusal is still counted and kept on the server under Edge traffic.
Only blocks, which take several things agreeing — a challenge must stay
passable, so it is never a decoy — and only page navigations: an API caller is
still refused with 403 Blocked. The cost is the person blocked by mistake: they
see an empty page, and have no reference to quote. Turn it on where blocks are
rare and certain, such as a site scraped hard by clients that never run the
agent.
Trap links
A scraper that runs no JavaScript never meets the agent, and its headers can
be made to look like anything. What it cannot help doing is walking the links
it finds. With the risk call configured, the edge answers a trap path —
/_ab/t and everything under it — that the site links to where no person can
see or reach it:
<a href="/_ab/t/" rel="nofollow" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden">​</a>
and that robots.txt closes to crawlers:
User-agent: *
Disallow: /_ab/
On Cloudflare, without touching the site: ANTIBOT_TRAP_INJECT=on has the
Worker place both itself — the link appended to the <body> of every 200 HTML
page fetched with GET (by HTMLRewriter, streaming, as an element, so it can
never land inside a script or an attribute; a page sent with
Cache-Control: no-transform is left alone), and the prefix disallowed in
robots.txt (after the site's own rules, untouched; a site with none gets one
that says only this). The Worker has to run on the site's routes, not only on
the prefix, as for enforcement. If placing it fails, the page goes out as the
site sent it.
A request for it is reported to the Server API
with its headers, and the address is remembered for six hours: every
assessment from it without a valid receipt carries trap:followed (60) — a challenge on its own, a
block beside anything else. From a VPN's, a privacy relay's or a Tor exit,
shared by thousands, the one who followed it is any of them: it brings the
request up to a challenge and no further (server API). The answer is an ordinary-looking page that links
three levels deeper, each of which reports again. Never counted:
- a browser's prefetch (
Sec-Purpose: prefetch, or the olderPurposeandX-Moz) — speculation rules or an extension fetching every link; - another site's embed (
Sec-Fetch-Site: cross-siteorsame-site) — the way to get someone else's address flagged would be to put the trap URL in an image on a page they visit; - anything but a page fetched by navigation (
Sec-Fetch-Destother thandocument,Sec-Fetch-Modeother thannavigate) — an<img>of the trap posted in a review on your own site is requested by every reader, from the same origin; a browser too old to send fetch metadata is told apart by itsAccept(an image asks for images; a page or an HTTP library asks for HTML or*/*); - a person following a link someone sent them (
Sec-Fetch-User: ?1); - a verified crawler — one that reached the trap followed a link the site
published without
nofollow.
A browser that ran the agent and carries a valid receipt is not held to it:
the agent plants its own honeypot. Six hours, not
days, because a scraper's address is often a residential proxy's, and the
household behind it tomorrow did nothing. Addresses that walk into a trap
count against them in network reputation
too. ANTIBOT_TRAP=off turns the path off; it does nothing until something
links to it.
Probes: the trap links every site already has
Before a vulnerability scanner attacks a site, it asks for what a careless
deploy leaves in the web root: /.env, /.git/config, /.aws/credentials,
/wp-config.php.bak, /.DS_Store, /phpinfo.php, /etc/passwd by way of a
path traversal. No person requests these and nothing links to them — they are
trap links nobody had to write. Wherever the edge enforces anything (crawler
refusals included), it answers them itself with a 404, so the site behind never
serves a file it may have left lying there, and reports the request like a trap
hit — once a minute per address — with the same exclusions (another site's
embed, a prefetch, a verified crawler): the address is remembered for six
hours, so what it asks for next carries trap:followed. A path is read as an
origin may read it — a ; parameter dropped, a backslash a separator, a
trailing dot gone, nothing past a NUL — so /.git;/config and /.env. are
probes too. Assessed from your own server, a probe's path scores
probe:<what it was after> — secrets, sourceControl, editorFiles, debug,
traversal — at 80, a block, and marks the address the same way.
Only paths nothing legitimate uses are on the list: not /phpmyadmin or
/actuator (an admin may run them), not /cgi-bin or /server-status, not a
.sql or a .zip (open data is published that way). A repository served over
HTTP is /user/repo.git/…, never .git alone. And only near the root — the
first segment or the second (/.env, /app/.env, /repo/.git/HEAD): deeper,
the same names are pages, a code host's /user/repo/blob/main/.vscode/… or a
docs site's. ANTIBOT_PROBES=anywhere reads them at any depth;
ANTIBOT_PROBES=off lets them all through to the site.
Walks: pages the site has not got
A scanner mapping a site's unlinked paths — /admin, /backup,
/api/v1/users/1, /api/v1/users/2… — asks for page after page the site does
not have, and the site says so: a 404. The edge watches those answers. Twenty
different pages a site has not got, asked for from one address in ten
minutes, are a walk (OWASP's footprinting), reported as a trap hit: the
address is marked for six hours, and its requests without a receipt are
assessed with trap:followed, as if it had followed the trap link. Unlike a
trap hit, a walk is not told to other sites. A person mistyping, or following
a few dead links, comes nowhere near; many people behind one address clicking
one broken link are one page, not twenty. An IPv6 address counts as its /64,
as the mark is kept: a walk spread over one, a page an address, is one walk.
The ten minutes slide: a walk is not cut in two by where a window began.
What is counted is what the trap counts: a page fetched by navigation on the
client's own initiative — never prefetched, never an image, a script, a style
or a call a page's own script makes (Sec-Fetch-Dest, Sec-Fetch-Mode), a
search-as-you-type lookup the API answers 404 included; never a page a person
clicked through to or typed (Sec-Fetch-User: ?1), however many of a dead
site's links they try; never from another site — or, from a client that sends
no fetch metadata (an HTTP library, an old browser), a request that asks for
HTML, for anything, or says nothing. A verified crawler is not marked whatever
it asked for; one that only says it is a crawler is, as a scanner can say it. On by default wherever the edge has an assess key, on every path,
listed or not; ANTIBOT_WALKS=off turns it off. It needs the site's answer to
pass through the edge, as a sign-in's outcome does: the Cloudflare Worker,
Fastly and Azure Front Door see it; the forward-auth service
and CloudFront's viewer-request function do not, and count nothing. Each edge
instance counts its own 404s: a walk spread thin over many is missed, a walk
from one address is not.
Crawlers, by what they are for
A crawler is no longer just a bot: it indexes for search, previews a link in a
chat, collects pages to train a model, indexes for an AI answer engine, or
fetches a page because a person asked an assistant about it. The large AI
operators send a different name for each — OpenAI's GPTBot trains,
OAI-SearchBot indexes for ChatGPT search, ChatGPT-User fetches for a
person; Anthropic's ClaudeBot, Claude-SearchBot and Claude-User likewise,
and Mistral's, Meta's and Amazon's (whose Amazonbot Amazon says may train its
models, so it is aiTraining; Amzn-SearchBot indexes and Amzn-User fetches
for a person) — each named as its operator documents it. Google's AI products
(Google-CloudVertexBot, Gemini-Deep-Research, GoogleAgent-*), Moonshot's
Kimi, xAI's Grok, DeepSeek, Baidu, Alibaba's Qwen and Tongyi, ByteDance's
Doubao and TikTok, Huawei's PanGu and some twenty more are read as
ai.robots.txt records their
operators' purposes; a name only robots.txt reads (Google-Extended,
Applebot-Extended) is sent by no crawler, and a word an app's own web view
might carry is left out. The edge can
refuse whole categories on every path, with no call to the API and nothing
else configured:
"ANTIBOT_REFUSE_CRAWLERS": "aiTraining", // or "aiTraining,aiSearch"
"ANTIBOT_CRAWLER_LICENSE_URL": "https://example.com/ai-licensing" // optional
Categories: search, social (link previews), seo, monitoring,
aiTraining, aiSearch, aiAssistant; unknown names are ignored. A refused
crawler gets a 403 (antibot-enforcement: crawler:aiTraining), or — with a
license URL — a 402 Payment Required with Link: <url>; rel="license": the
site is not closed to it, it is not free. robots.txt is always served, so it
can read why. The answer varies by User-Agent, so it is no-store and
Vary: User-Agent. With ANTIBOT_ASSESS_SECRET_KEY set too (an assess-only key
is enough), the refusals are counted under Edge traffic → Crawlers, by what
they are for: reported in batches — the first at once, then every ten seconds
or hundred refusals — as counts by purpose, nothing else.
Refusing on the User-Agent alone is sound — nobody wears a crawler's name to be refused. Letting one through on it is not, which is what crawler verification is for, and why this list only ever refuses.
Two names are not User-Agents and cannot be refused here: Google-Extended and Applebot-Extended are robots.txt tokens. Googlebot and Applebot crawl for search and AI alike; to keep your pages out of Gemini or Apple's models, say so in robots.txt:
User-agent: Google-Extended
Disallow: /
User-agent: Applebot-Extended
Disallow: /
The assessment reports the same reading as
declaredCrawler: { name, operator, category }, and rules see
crawler.category, crawler.name and crawler.operator — the editor's
Refuse AI training crawlers template is the server-side form of the setting.
Custom subdomains
For customers who cannot put a worker in front of their site. You point DNS here directly and the agent is served from your own origin.
Custom domains → Add subdomain. The dashboard hands you the whole record set as a zone file; the Management API returns it with the domain so a provisioning script can create the records in the same run.
Setup mirrors what any certificate authority asks for: a TXT record proving control of the zone, A records routing traffic, and a CAA record only when an existing one would block issuance.
Verification resolves the zone for real, against public resolvers rather than the host's own — a stub resolver may be answering from cache or a split-horizon view, and the question is what the internet sees. An unverified claim lapses after fourteen days, and holds nothing meanwhile: any organization may list your site among its allowed origins and claim a name on it, so only the claim that proves the name is active, and another's unproved claim never keeps you from your own subdomain.
Keep the TXT record. The scheduled job (/api/cron) looks at an active
domain's proof again once a day. Missing, it says so on the domain; missing for
a week — a week, because a resolver has its bad days — the claim lapses to
failed, and has fourteen days to be checked again once the record is back.
Until then the name is free for whoever proves it: a zone that changed hands is
its new owner's. Addresses that point elsewhere are said, and lapse nothing.
Three rules, enforced before a name is accepted
- It must be a subdomain, not an apex. An apex cannot carry these records alongside your own website.
- It must be on the same site as your traffic, or the requests are not
first-party and nothing has been gained. A site is the last two labels of a
name, or three under the common second-level suffixes (
acme.co.uk,acme.com.au,acme.co.jp, …):metrics.other.co.ukis not onshop.acme.co.uk's site. Your traffic is your allowed origins: with none set, every origin is yours, and this rule has nothing to hold a name to — the TXT proof above still has to be published in the name's own zone. - It must not describe what it does. A name containing
fingerprintorantibotis matched by content blockers, which defeats the feature. The check reads only the labels you chose, not your registrable domain — a company whose site isfingerprint.comcan still add a subdomain.
What the host means
A hostname is held by exactly one organization, and only once verified.
- A request on a verified host must carry a key belonging to the
organization that owns it. A key from anywhere else gets
DomainMismatch. Owning the DNS name is not enough and holding a key is not enough — they have to agree. - The host decides the environment, not the key. A staging subdomain cannot deposit traffic in production even with a production key.
- A pending host means nothing at all. The request is treated as if it arrived anywhere else, because an unproven claim must not attribute traffic to anyone.
- A verified host counts as an allowed origin for its own organization, so setting one up does not silently require a second, easily forgotten step.
Request filtering
Your public key ships inside every browser and app binary, so it is not a secret. Filters reject requests before any work happens — blocked requests get an error, are not billed, and leave no event behind.
Filtering → Add rule. Match on:
| Origin | including .example.com suffixes |
| IP or CIDR | |
| App package | |
| Bot class | indexing, ai, bad, any, or one bot's name as it is read (googlebot, gptbot, http-client); anything else is refused when saved — a crawler name its operator contradicts (see bot) counts only as bad |
| User agent | substring |
| Network (ASN) | AS16509 or 16509: every address in it — a hosting provider's, under a flood from its machines |
| Country | two letters (DE): an allow rule per country you serve is geo-fencing |
Block rules win outright. Adding one allow rule closes that dimension: once an origin allow rule exists, anything not matching it is rejected. So "only my own origins" is a single rule rather than an endless block list. An address whose network or country is not known is shut out by no list of them. A rule added, switched or deleted applies within five seconds: each server keeps an environment's filters that long between reads.
Edge traffic → Where refusals come from reads the last day's challenged and blocked requests by path, network, country and User-Agent, and blocks a network in one press. Block a network only when it is nothing but that traffic: a hosting provider's, not a mobile carrier's people browse from.
Filtering keeps working under IP pseudonymization, because it runs before anything is stored, on the address the transport already carried — all but network and country, which need the address looked up, and so match no address of an organization that pseudonymizes them. An identification is looked up before it is filtered only in an environment with such a filter.