← All docs

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 and allow lets 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: on allow it reached the site on its first request; on challenge it 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 carries antibot-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 (a GET or HEAD accepting text/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, noindex in 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 — gets 403 {"error":{"code":"ChallengeRequired"}}. The challenge and block pages speak the visitor's language from Accept-Language — English, Spanish, French, German, Portuguese (Brazil), Italian, Dutch, Polish, Swedish, Turkish, Russian, Ukrainian, Japanese, Korean, Chinese (simplified, or traditional for zh-TW, zh-HK, zh-MO and zh-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 — with Content-Language set 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, its User-Agent and 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 in web/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.

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">&#8203;</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 older Purpose and X-Moz) — speculation rules or an extension fetching every link;
  • another site's embed (Sec-Fetch-Site: cross-site or same-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-Dest other than document, Sec-Fetch-Mode other than navigate) — 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 its Accept (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.

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.uk is not on shop.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 fingerprint or antibot is matched by content blockers, which defeats the feature. The check reads only the labels you chose, not your registrable domain — a company whose site is fingerprint.com can 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.