← All docs

Browser agent

Install

Script tag

<script src="https://cdn.antibot.dev/agent/v1/antibot.js"></script>
<script>
  Antibot.load({ apiKey: "pk_live_…" }).then((agent) => agent.get())
</script>

ES module

import { load } from "https://cdn.antibot.dev/agent/v1/antibot.esm.js"

Bundler

npm install @antibot/browser
import { load } from "@antibot/browser"

The package is a loader, about 2 kB: load adds the agent the server serves to the page — from your first-party proxy when you give endpointPath (/_ab/agent beside /_ab/identify, on endpoint or, without one, on the page's own origin), else from the API — and returns it. Naming the API's own /api/v4/identify is not a proxy: the agent then comes from the API. It never bundles a copy of the agent: the server rotates the agent's build on every deploy and accepts only the builds it served recently, so a copy compiled into your application would stop identifying anyone two deploys after you shipped it. scriptUrl names the script outright, when it is somewhere else.

Browsers. The agent loads in any browser that parses ES2021: Safari 14, Chrome and Edge 85, Firefox 79 and later — every phone still updated, and iPhones held at iOS 14 or 15. pnpm agent:smoke parses every variant the server serves as ES2021 and fails the build that needs more, since a browser that cannot load the agent cannot load the challenge page that asks for it either.

load(options)

Returns a promise for an agent. Throws if apiKey is missing.

Option Type Default
apiKey string — Required. Your public key.
endpoint string origin serving the script (from @antibot/browser: the page's own origin when endpointPath names a proxy, else the region's API host) Override the ingest origin. Set this when serving first-party from another host.
scriptUrl string beside a proxy's endpointPath, else /agent/v1/antibot.js on endpoint @antibot/browser only: where the agent script is loaded from.
nonce string none @antibot/browser only: your Content Security Policy's script nonce, put on the agent's script tag — needed where script-src names a nonce without 'strict-dynamic', which refuses a script added without it.
endpointPath string /api/v4/identify Override the ingest path, for a proxy mounted elsewhere.
integrityPath string derived from endpointPath Where payload integrity keys are fetched. The default replaces the last segment of endpointPath with integrity, matching the proxy's default mount.
region "us" | "eu" | "ap" us Regional host.
cache "none" | "memory" | "sessionStorage" | "localStorage" memory Where results are cached.
cacheTtlMs number 30 min How long a cached result stays usable. Max 24 hours.
bindReceipt boolean false Bind a second receipt to a key this page holds and cannot hand on, for its API calls: agent.fetch (to the page's own origin) and agent.requestHeaders() send it with a fresh signature by the key, which an edge requires on the paths set in ANTIBOT_BOUND_PATHS, and a server SDK's guard on its boundPaths (API calls only your pages can make).
rememberVisitor boolean true Keep the signed hint of this browser's visitor every answer carries in the site's own localStorage (antibot:visitor) — and, behind the first-party proxy, as a cookie of the site's own (_abvh) — and send it with the next identification: a returning browser is compared with its own visitor first, so it is recognised even when its rendering drifted or it has not been seen for months. The hint decides nothing by itself (see Visitor ID). false keeps none and drops one kept before, the proxy's cookie too.
webrtc false | { stun?: string[]; timeoutMs?: number; when?: "idle" | "identification" } Cloudflare's and Google's STUN, 1 s, at idle The WebRTC leak check. The STUN server sees the visitor's address and nothing else; false turns the check off, stun points it at your own server (stun:stun.example.com:3478; the forward-auth service answers STUN itself with ANTIBOT_STUN_PORT). Capped at 3 s. By default ("idle") it runs after the page's first identification (or five seconds, if none comes), once the page is idle and nobody has pressed a key or a button for five seconds — a page's first RTCPeerConnection is one long task — and the session pulse's next beacon and the tab's identifications for five minutes carry its reading; "identification" runs it inside every identification, so that answer includes it, at that cost (see Cost to your page).

agent.get(options)

Option Type
linkedId string Your own account or order id. Searchable, and used by related visitors.
tag object Arbitrary JSON stored with the event. Searchable in the dashboard. At most 16 KB serialised; a larger one fails the identification with InvalidPayload.
extendedResult boolean Include the full signal set, not just identity.
ignoreCache boolean Force a fresh identification.
const result = await agent.get({
  linkedId: user.id,
  tag: { action: "checkout", cartValue: 129.99 },
  extendedResult: true,
  ignoreCache: true,
})

What the page sends is the page's to write, and so the visitor's: a linkedId or a tag set here is the browser's claim. A script can put another account's id on its own identifications, and that account then shows its devices in a search, among its related visitors and to the new device on a known account rule. Where the link must be yours, set it from your server once the account is known — PATCH /api/v4/events/{requestId} with linkedId (Server API) — and read what the browser sent as what it said.

On a page Chrome is prerendering — Speculation Rules, the address bar, a search result — get() waits until the page is shown (prerenderingchange): hidden and unpainted, it is no one's yet, and may never be. Nothing is sent, and nothing is counted, for a prerender the visitor never opens.

Result

{
  "requestId": "msdcg6f1.kRuTmb",
  "visitorId": "CTBiu58afNEqTbn1hWj2",
  "visitorFound": true,
  "confidence": { "score": 0.9997, "comment": "exact match" },
  "incognito": false,
  "suspectScore": 8,
  "browserDetails": { "browserName": "Chrome", "os": "Windows" },
  "ip": "203.0.113.9",
  "ipLocation": { "city": { "name": "Madrid" }, "country": { "code": "ES" } },
  "timestamp": 1785719825943,
  "time": "2026-08-03T12:37:05.943Z",
  "url": "https://example.com/checkout",
  "linkedId": "user_42",
  "tag": { "action": "checkout" },
  "products": { "…": "with extendedResult" },
  "decision": { "action": "allow", "matched": [] },
  "sessionToken": "ab1.eyJ…"
}

requestId is the value you send to your backend. Everything else is convenience — treat it as display data, not as grounds for a decision.

Session tokens

sessionToken is the verdict as a signed, self-contained token (ab1.<claims>.<hmac>), valid for ten minutes. Your backend verifies it with the organization's session key — the Node SDK exposes verifySessionToken(token, key) — so the requests that follow an identification can be checked locally, without calling the API again. The claims carry the visitor id, the suspect score, the rules engine's action, and a sig list naming the signals that fired — the per-test breakdown behind the score, so your policy can weigh what failed, not just how much. Like every decision here it is advice: acting on it is your call.

A token is also bound to the client it was minted for: the ub claim is a keyed tag of the User-Agent header the identification arrived with. Pass the presenting request's own header to the verifier and a token carried to another client fails as bindingMismatch, without being spent:

verifySessionToken(token, keys, { userAgent: request.headers["user-agent"] })

Pass the header as Node gives it to you, undefined included: the check runs whenever the userAgent option is present, and a request that sent no UA matches only a token that was itself minted for a client with none (stripping the header at identification earns a token bound to having none, not one any client may present). A User-Agent with non-ASCII bytes is compared after each run of them is collapsed to one ?, because runtimes decode such a header differently.

This is a cheap brake, not an identity check — copying the string defeats it — and only the Node SDK and the edge enforcer check it; the other SDKs ignore the claim. Do not turn it on where something other than the identifying browser presents the token: a server-side renderer or backend-for-frontend forwarding the visitor's cookie with its own User-Agent, or a native app whose HTTP client sends a different one, would be refused as bindingMismatch.

Check the receipt's environment

An organization's environments share its session keys, so a receipt minted in one verifies in any other: one earned against staging's rules, or under a monitoring environment's, verifies on production's backend just the same. The edge enforcer and the SDK guards know which environment their key speaks for and read a receipt from another as no receipt. Verifying one yourself, compare its env claim with the environment you serve:

const PRODUCTION = "env_…" // the environment this backend serves
const verdict = verifySessionToken(token, keys)
const ours = verdict.valid && verdict.claims?.env === PRODUCTION

An environment's id (env_…) is what the Management API's GET /environments lists, and the env of any receipt it minted. With one environment there is nothing else a receipt could be from.

Each token also carries a unique jti. Where a token should authorise one action rather than a whole ten-minute window — a challenge-verified unlock, a high-value endpoint — hand the SDK verifier a replay cache and the first presentation spends it:

import { createReplayCache, verifySessionToken } from "@antibot/node-sdk"

const spent = createReplayCache()
const verdict = verifySessionToken(token, keys, { replayCache: spent })
// verdict.reason === "replayed" on any second presentation

By default the agent also writes the token to the _abid cookie (Path=/, SameSite=Lax, ten-minute Max-Age matching the token's life), so the edge enforcer — or your own CDN logic — sees the verdict on every following request without any backend plumbing. An identification answered block with no token — a revoked visitor — clears it, and the receipt requestHeaders() sends, rather than leaving the one before to be presented until it expires. Set sessionCookie: false to keep the token out of cookies.

Session pulse — the verdict keeps moving

Identification scores a moment. While the token lives, the agent also sends small pulse beacons (default every 15 seconds, slower in a hidden tab, and slower still — every 30, then 60 seconds — for a session that has only been saying a person is there, at the server's word (nextMs); sendBeacon as the page is hidden or left, its body typed as text so that a site on another origin than the API needs no preflight for it — once, however many of the browser's events say so, and not within 2.5 seconds of the last beacon, which the server would read as a script's) carrying what changed since the last one: interaction deltas, how much of the window the page was actually attended, SPA navigations, per-window motion statistics, and runtime drift flags — the user agent, platform, screen or navigator.webdriver changing under a live session. A page pulses for eight hours at most — through a waiting room's longest queue — and then stops.

Each beacon proves it comes from the page that identified. The identification's answer carries a pulseKey beside the receipt, and every beacon an HMAC over its tab and sequence number under it (proof), computed in the agent's own JavaScript so a page over plain HTTP and the beacon sent as the page is hidden can sign too. The key is never in the receipt: one carried off with the cookie — into an HTTP library, the way a solved challenge is handed on — cannot pulse for its session (403, proof), and the edge reads the pages it fetches as never run (receipt:pagesNotRun).

The server re-scores the session on every beacon. The rules it applies, worth knowing because they shape what your integration sees:

  • drift is sticky — a runtime property changing mid-session raises the score and stays raised; flipping it back is not innocence;
  • clones expose themselves — beacons carry a per-tab sequence number, so a token presented from two places (or a replayed recording) produces a sequence regression that is scored, not ignored;
  • metronomes are caught — three byte-identical activity windows in a row is a script, not a person, and the same goes for cadence: typing whose flight time barely varies (metronome_typing) or a pointer gliding at constant speed (constant_velocity);
  • a GPU rewritten in WebGL is found later — once identification is over, the agent asks WebGPU which GPUs the machine has (both power preferences; once per tab, kept in sessionStorage; only under a Windows User-Agent or beside an Apple silicon GPU in WebGL, where the answer is read) and the next beacon — the first of each new token's session — carries that beside WebGL's renderer. A maker in WebGL that no WebGPU adapter names, on Windows — where Chrome serves both from one GPU — is a challenge (gpu_contradicts_webgpu); an Apple silicon GPU in WebGL beside another maker's adapters is a conviction (gpu_not_apple_silicon), since such a Mac has that one GPU. It is never asked during identification: the first requestAdapter took 2.7 s in headless Chrome;
  • a proxy between is timed — behind an edge that knows the connection's round trip (Cloudflare; the forward-auth service with ANTIBOT_FORWARD_RTT=on), the identification's answer starts a chain of pings, and once the page is idle the agent sends four to the edge's /rtt (rttPath), each carrying the token the last answer sealed. The edge times the wait from its answer to the next ping — a client can lengthen it, never shorten it — against the connection's round trip, and the next beacon carries the chain. A browser 150 ms and three times further than its connection is noted (rtt_beyond_connection, 30); 400 ms and four times, a challenge (50): the connection ends at a proxy that terminates TCP, a residential pool's exit, and the browser is beyond it. The tab keeps the chain for its next page, whose identification carries it to the proxy signal (relay_latency). A chain whose answers the page took up late (a busy main thread) is not sent;
  • a form filled whole is read — text arriving in a field in one trusted insertion with no key pressed for it (a driver's fill()) is noted (text_inserted_whole, +20, sticky) on a desktop;
  • clicks reached in one jump are read — every click of a window, two at least, that the pointer reached in a single sample from 40 px away or more (a driver's or a computer-use agent's) is noted (jumped_clicks, +20, sticky);
  • a driver's own code is read after identification too — a fetch, a script's click or a navigation (history.pushState included) from a Puppeteer or Playwright evaluate, made after the page identified, is carried by the next beacon (driver_evaluation, +70, sticky): a single-page app identified once and driven after is blocked;
  • hidden tabs are never punished — an unattended window with no activity is only suspicious when the page claims it was visible and focused;
  • recovery is small — coherent human activity lowers the score a little, floored near the score the token was minted with, and stops entirely once any sticky flag has fired.

Every note a beacon's answer can carry (notes in the verdict onRiskChange receives), with what it adds to the session's score. The session starts at the identification's score; 30 is flag, 50 challenge, 70 block. A sticky note is added once a session; the others each time they are seen.

Note Adds Sticky What it is
seq_regression 50 no A beacon numbered before one already seen: the token presented from two places, or a recording replayed
tab_churn 10 yes The token's beacons coming from more tabs at once — nine within thirty seconds — than a person has open; pages gone through one after another are heard one at a time
pulse_too_fast 10 no Beacons from one tab faster than the agent sends them; two tabs' beats falling together are two tabs
webdriver_engaged 40 yes navigator.webdriver turned on under the live session
ua_drift, platform_drift 35, 30 yes The User-Agent or the platform changed mid-session
language_drift, screen_drift, cores_drift, memory_drift, cookies_disabled 15, 10, 10, 10, 5 yes Those properties changing mid-session
gpu_contradicts_webgpu, gpu_not_apple_silicon 50, 70 yes WebGL's GPU rewritten, found against WebGPU's (above)
rtt_beyond_connection 30 or 50 yes The browser timed further away than its connection (above)
webrtc_leak 15 yes The browser's UDP path seen from another network than its beacons (above)
driver_evaluation 70 yes A driver's evaluated code calling into the page (above)
metronome_activity 20 yes Three identical activity windows in a row
active_but_inert 8 no A visible, focused window in which nothing happened; three windows at most
jumped_clicks, centred_clicks 20 each yes Every click of a window reached in one jump, or on the exact centre, two at least
text_inserted_whole 20 yes A field's text arriving whole, on a desktop
pointer_between_pixels 20 yes A mouse stepping between the screen's pixels, under Windows
untrusted_events 15 yes Scripted events outnumbering the browser's own two to one
metronome_typing, keys_back_to_back, constant_velocity 15, 20, 10 yes Typing or a pointer with a machine's cadence (above)
coherent_interaction −4 no A person's activity, with nothing else noted: the recovery above

The dashboard's Smart Signals page shows, under What the session pulse found, how many of the last day's sessions raised each sticky note — a session's record is kept a day — so a note raised in a share of your people's sessions can be weighed before a rule acts on it.

When the running action hardens to block, the server writes a token revocation (which offline verifiers poll) and the agent clears _abid and stops pulsing — an edge enforcer in deny posture then refuses the session's requests outright. A 401 answer means the token expired; the agent drops its cached result so the next get() identifies afresh.

const agent = await Antibot.load({
  apiKey: "pk_live_…",
  sessionPulse: true,        // default; false disables beacons
  pulseIntervalMs: 15_000,   // default; clamped to >= 5s
  onRiskChange: ({ action, previousAction, score, notes }) => {
    // Called when the standing moves. `block` is already handled (receipt
    // dropped); `challenge` is a good moment to offer solveChallenge() —
    // or set `stepUp: "overlay"` and the agent shows the hold itself.
    if (action === "challenge" && previousAction !== "challenge") {
      // …render your press-and-hold and call agent.solveChallenge(el)
    }
  },
})

The pulse endpoint rides the same origin and path derivation as identification — through a first-party proxy it is /_ab/pulse — and is authenticated by the session token itself, so beacons carry no separate integrity machinery.

Page scripts

On a page your environment watches for its scripts (Page scripts in the dashboard), the agent lists the page's scripts once it has loaded — and those added after — and sends them for the inventory PCI DSS 4.0 asks of a payment page (Page scripts): external ones by URL, inline ones by their CSP hash, never their text. Nothing runs on a page nobody watches. scriptInventory: false turns it off. Through a first-party proxy the list goes to /_ab/scripts.

Behavioral biometrics

The agent passively accumulates the statistics of motion — pointer speed, turn rate and smoothness; keystroke dwell and flight; click-hold duration; scroll pacing — as Welford moments (count, mean, variance). The cumulative report rides identify, and a per-window summary rides every pulse.

Two properties are worth knowing before you reason about consent banners:

  • Nothing readable leaves the page. No key identity is ever read, no pointer coordinate is stored in the accumulators, no raw event stream is retained or transmitted. The wire carries statistics that cannot be reversed into what was typed or where the pointer went — by construction, not by policy. (The trajectory sent with identify is the bounded, documented exception: the last 128 pointer positions, positions only.)
  • Idle is fine. A modality that never happens — a page with no typing — simply omits it; absence is never scored as evidence.

Server-side, the report feeds outright cadence rules (metronome typing, constant-velocity pointers, inhuman click holds — thresholds an order of magnitude inside bot territory) and, once a visitor has three trusted sessions of history, a comparison against their own learned cadence: the same device in different hands is the behavioral shape of account takeover. See the biometrics signal reference.

Breached-credential checks

When your login form submits, you can ask whether the pair is in your breach corpus — without the password ever leaving the page:

const result = await agent.checkCredentials(username, password)
// { compromised: true, source: "feed", label: "2019-breach", checkedAt }
// A lookup failure resolves to { compromised: false, source: "error" } —
// the breach check never breaks a login form.

The agent hashes the normalized pair (`username.trim().toLowerCase() + ":"

  • password`, SHA-256) with WebCrypto, sends only the first 40 bits of the digest, receives every suffix in that bucket, and compares locally. The password, the username and the full digest never cross the lookup wire.

Two things follow from one call:

  • The answer is yours to act on immediately — force a reset, step up to MFA — before any server round trip of your own.
  • The digest (still never the password) rides the next get(), where the server matches it against the corpus itself: the ato_credential signal, a result the client cannot forge. It is also how the corpus learns — a digest presented by a session convicted as automation is recorded as actively stuffed, so the next session presenting it (whatever it claims to be) reads as a stuffing attempt.

Your backend can run the same lookup server-to-server with a secret key: POST /api/v4/credentials/lookup with { apiKey, prefix }. The corpus is managed over the Management API and is yours alone — there is no cross-customer list.

Waiting rooms

When a page sits behind a waiting room, the agent drives the whole flow — identify if needed, enter, poll at the server's suggested cadence, resolve on admission:

const result = await agent.enterQueue("spring-drop", {
  onUpdate: ({ position, etaSeconds, status }) => renderPlace(position, etaSeconds),
  signal: abortController.signal, // optional — cancels the wait
})
if (result.status === "admitted") {
  // result.token is the admission receipt; your edge verifies it offline.
}

Entry is gated on the session's own verdict: a session convicted as automation is refused the room, a hot score is held, and a second tab of the same visitor gets its own position back — one spot per visitor, by construction. If you render nothing yourself, point the traffic at the hosted room (/queue/<slug>?key=<public key> on your deployment) and it runs this same flow, redirecting admitted visitors to the room's configured URL with the token in ?abq=.

Challenges

When the rules engine answers challenge, the press-and-hold flow is how a human proves it. You render the button; the agent measures the hold and solves a proof-of-work in the background while they do:

const verdict = await agent.solveChallenge(document.getElementById("hold"))
if (verdict.passed) {
  // verdict.sessionToken has the `ch` flag set — it lifts a `flag` or
  // `challenge` verdict; re-check it against your own policy on the backend.
}

The element is yours: the library records hold duration, pointer drift and pressure variation, it does not draw anything. A solved challenge is single-use and expires after five minutes unsolved.

It also records where a mouse pressed and how it got there. A driver's hold — Puppeteer's mouse.move to the box's middle, then down, a wait and up; Playwright's the same — lands on the element's exact centre with the pointer set down on the spot; a hand travels to the button and lands anywhere on it. The two together fail the hold (holdInhuman, noted pressedLikeADriver), measured with real Chrome in web/probes/interaction.mjs; either alone does not — a pointer resting where the button appeared, a press that happens to hit the middle. Nor does a mouse set down on the button: arriving from 40 px or more in one or two samples, as a computer-use agent's xdotool mousemove puts it — off the centre, where a model reading a screenshot aims — fails the hold too (jumpedToTheButton), where a hand's reach takes several frames' samples however fast it is (measured with real Chrome in web/probes/step-up.mjs). A key's hold is not read this way.

It can be held without a pointer, too: Space or Enter held on the focused element is a hold, as a pointer's is — for the keyboard, a switch, a screen reader in focus mode (WCAG 2.1.1 asks that it be operable that way). The key's own repeats are not presses, focus leaving ends the hold, and a script's key events are refused as its pointer events are (they are not trusted). So render something that can take focus and say how to use it:

<button id="hold" type="button" aria-describedby="hold-help">Press and hold</button>
<p id="hold-help">Hold the button, or focus it and hold Space, until it says you're done.</p>

and announce the result in a live region (role="status"), as the edge's own challenge page does. The verdict's telemetryNotes say keyboardHold for a key's hold, where a pointer that never moved would have said noMovement.

What a pass is, and is not:

  • It prices automation; it does not prove a person. The proof-of-work is real cost, but the hold telemetry is the client's own report. The agent filters it to trusted events and the server refuses anything synthetic, short or perfectly straight — yet someone calling the API directly can write those numbers themselves. Treat ch as one more input, not a verdict.
  • It is issued on an identification. The agent presents the session token it holds when it asks for the challenge, and the server checks that token — signature, expiry, organization, environment, that it is an identification (not a queue admission, not itself earned by a challenge, and not a phone's key-bound receipt, good only with the phone's signature on each request), that its identification proved a browser (the token's pv: its key's program was answered as a browser answers it), and that it comes from the same client (User-Agent) it was minted for. Only then is the challenge bound (bound: true): to that visitor, that session and that client. A visitorId on its own is just a name the caller typed and is not read — not even for the price, which would tell anyone how suspect any id stands — so a bot that knows a clean visitor's id but not their receipt gets an unbound challenge, at the base price. A bound one is priced by the visitor's latest verdict, or the session's where the pulse has moved it since. Solving an unbound challenge is a valid proof with nobody to vouch for, so it is not passed: you get passed: false, reason: "unbound". If the page's token is stale the agent drops its cache, identifies again and retries once; if identification issued no token (a revoked visitor, say) solveChallenge returns unbound straight away rather than have the visitor hold a button for nothing. Agents older than this change never send the token and so never earn one.
  • What a hold stands on is the browser, not its numbers. The hold's telemetry is the client's own report: a script that holds a token can write plausible numbers, wait half a second and pay the proof of work. So a token whose identification did not prove a browser — one that never ran its key's program, as an HTTP client does, or one attack mode challenged as unproven — gets an unbound challenge and earns nothing from it. The agent, told so, identifies again (running the program) and asks once more, so a person never notices; a script is left where it was. (Where the agent builds served run no program at all, there is nothing to have proved and this does not apply.)
  • A pass speaks only for that visitor, from that session, and the verify call has to come from the same client the challenge was issued to (reason: "bindingMismatch" otherwise) and under the same environment's key (wrongEnvironment). Nothing in the verify request chooses the subject. A solve that arrives after the session's own token expired earns nothing (sessionExpired), so one identification cannot be traded for fresh tokens indefinitely: the trust it can yield is bounded by the token's life plus the challenge-earned token's.
  • It proves a hand, not another browser. The token it earns says what the token it was issued on said of the browser: the signals that fired (sig) and the handshake it arrived on (tf). An edge that fails a receipt for bot fails the one a solve earned too, and one that sees an HTTP library's handshake on it can tell, as it could on the first.
  • The time a hold took is the one part of it the server can check against its own clock. A solve that arrives sooner than a hold's worth of time after issuance, or claims a hold longer than the time that has passed, did not happen (holdImpossible). It authenticates nothing — a caller can wait half a second — but it makes the half second real, and a hold with no durationMs at all is not a hold. Nor is one that leaves out what every agent's hold says (samples, pathEntropy, trustedRatio, pressureChanges): holdIncomplete. Leaving the numbers out was the cheapest way past every check that reads them; writing plausible ones is still possible, which is why the work and the counts after a pass are what hold.
  • A person passes a hold when asked, once a session, now and then twice. A visitor passing a third within the hour is refused the fourth (holdsTooOften): a client whose sessions keep being hardened, holding its way back each time into a session with nothing on it.
  • It never overturns a block: whether the rules engine issued it at identification, or the pulse engine reached it on that session mid-session (the session's own state counts, and a revoked session earns nothing). A blocked visitor who solves gets passed: false, reason: "verdictStands", and a visitor the environment has never identified gets reason: "unknownVisitor". Use challenge, not block, for the cases a person should be able to talk their way out of.
  • The audit row for a challenge records what the caller was told: a solve the proof-of-work passed but the server then refused reads failed with the reason, not passed.
  • A hold no hand made has consequences. Events a script dispatched, or a line drawn in perfectly straight steps, end the session the hold was asked on (its receipt is revoked) and the device's next identification carries it (holdNotAHand, in the behaviour signal); a driver's press on the centre counts after two.

The hold over the page: stepUp

The agent can draw the hold itself. With stepUp: "overlay", when the session's standing becomes challenge — a receipt minted on a challenge, or a pulse that hardens the session after it — the press-and-hold is shown over the page, and the receipt it earns is written in place of the old one:

const agent = await Antibot.load({ apiKey: "pk_live_…", stepUp: "overlay" })

It is what the edge's step-up page is for a page navigation, for a single-page app: one that routes on the client may never navigate again before its session hardens, so a hold that waits for the next page never comes. The overlay is a modal dialog in a closed shadow root (the page's styles do not reach it, nor its own the page), above everything, in the visitor's language — the API answers the words with the challenge, from the same table as the edge's pages — with the button focused once the hold is being measured, so Space or Enter holds it as a pointer does. A hold that does not count is asked for again; the dialog goes when one passes, and the focus goes back where it was. Escape closes it, as it closes a dialog, with nothing earned (reason: "dismissed") and the hold's work stopped: what it was for stays refused until a hold passes, and on that page it is shown again only when asked for (stepUp(), agent.fetch), not on the session's own word.

agent.stepUp() shows the same dialog on demand and resolves with the challenge's verdict once a hold passes — what to call when your own API answers that a step-up is due:

const response = await fetch("/api/checkout", { method: "POST", body })
if (response.status === 403) {
  const verdict = await agent.stepUp()
  if (verdict.passed) await fetch("/api/checkout", { method: "POST", body })
}

Behind an edge that refuses a site's API calls until a step-up is passed (ANTIBOT_ENFORCE_STEP_UP=hold-all), agent.fetch does that for you: it sends the request with the page's own fetch, and on a 403 carrying antibot-step-up: hold shows the hold, then sends the request once more when it passes — any other answer comes back as it was. A body that is a stream cannot be sent twice; a string, a FormData or a Request can.

An API on another origin than the page (www. calling api.) reaches the page's code only through CORS, and agent.fetch has to read antibot-step-up on the refusal. The edge says so itself to a call from the same site. Behind a server SDK's guard, your own CORS settings answer: allow the page's origin with credentials, as for any call that carries cookies, and expose antibot-step-up and antibot-enforcement (the cors package's exposedHeaders, Access-Control-Expose-Headers wherever it is set) — unexposed, the page sees the 403 and never the step-up it asks for.

const cart = await agent.fetch("/api/cart").then((r) => r.json())

One is shown at a time: a call while one is up is answered with it. Off by default — what a challenge asks for is yours to show (onRiskChange, solveChallenge) — and set for you where the edge places the agent and steps receipts up (data-antibot-step-up="overlay" on its tag). The dialog costs the bundle about 3 kB minified; it is left unobfuscated, being a dialog and its words, while the hold it runs — what is measured and sent — is not.

API calls only your pages can make

A receipt in a cookie can be carried off: a solver's browser earns it, and an HTTP client that copies the browser — its User-Agent, its headers, its TLS handshake — presents it for the ten minutes it lasts. For your API, which only your pages call, the receipt can be bound to the page instead. With bindReceipt: true the agent makes a key in WebCrypto, not extractable — the browser signs with it and hands its private half to no one, your own scripts included — and keeps it in the site's IndexedDB; each identification then answers with a second receipt bound to it (boundSessionToken, receiptBound). Every call your page makes through agent.fetch to its own origin carries that receipt (antibot-token) and a fresh signature by the key over the call's method and path (antibot-pop, good two minutes); a call elsewhere takes them from agent.requestHeaders(method, pathAndQuery). The edge reads only that receipt, with its signature, on the paths you set in ANTIBOT_BOUND_PATHS (first-party), and a server SDK's guard on its boundPaths (server SDKs): the cookie, or the bound receipt copied without the key, is no receipt there.

const agent = await Antibot.load({ apiKey, bindReceipt: true })
await agent.get()
const orders = await agent.fetch("/api/orders?page=2").then((r) => r.json())
// Another origin's API:
const headers = await agent.requestHeaders("GET", "/v1/orders?page=2")
await fetch("https://api.example.com/v1/orders?page=2", { headers })

A bound receipt about to lapse is renewed before it is sent, and a call the edge refused for want of one is sent again, once, on a fresh one. The pages keep the cookie: a page navigation cannot carry a header. The receipt is bound to the browser's key, not to its hardware — a script driving that browser can sign through it — so what it stops is the receipt carried elsewhere, which is how a solved challenge is resold.

Caching

Caching trades freshness for latency and cost. A cached result is returned without a network round trip, which is why memory is the default, but the signals in it are as old as the cache entry.

Strategy Survives
none nothing — every call hits the API
memory the page
sessionStorage reloads, within the tab
localStorage the browser closing

Concurrent calls to get() are coalesced, so several components asking on one page produce one identification rather than several.

A page whose get() is answered from the cache does not identify, and pulses for the cached session instead, as a tab of its own: with sessionStorage or localStorage, every page of the session is heard, not only the one that earned its token — which an edge that places the agent counts a receipt's pages against (first-party). Each tab's beats are held to that tab's own pace, so two tabs sharing a cached result are two tabs. A token the cache holds past its life is not pulsed for; the next identification replaces it.

Pass ignoreCache: true anywhere a decision is made. Anything acting on bot or VPN state wants the current answer, not a 20-minute-old one.

Framework packages

Each mirrors the same shape, so moving between them is not a relearn.

Package Covers
@antibot/browser vanilla JS
@antibot/react React, Next.js, Preact
@antibot/vue Vue, Nuxt
@antibot/angular Angular
@antibot/svelte Svelte, SvelteKit

React

import { AntibotProvider, useVisitorData } from "@antibot/react"

function App() {
  return (
    <AntibotProvider options={{ apiKey: "pk_live_…" }}>
      <Checkout />
    </AntibotProvider>
  )
}

function Checkout() {
  const { isLoading, error, getData } = useVisitorData()
  // getData rejects when the identification fails, and `error` says so too.
  return (
    <>
      <button disabled={isLoading} onClick={() => getData({ ignoreCache: true }).catch(() => {})}>Pay</button>
      {error && <p>Something went wrong. Try again.</p>}
    </>
  )
}

Vue

import { antibotPlugin, useVisitorData } from "@antibot/vue"

app.use(antibotPlugin, { apiKey: "pk_live_…" })

const { data, isLoading, error, getData } = useVisitorData()

Angular

import { Component, inject } from "@angular/core"
import { AntibotService, provideAntibot } from "@antibot/angular"

// bootstrapApplication, from @angular/platform-browser; an application of
// NgModules imports AntibotModule.forRoot({ apiKey }) instead.
bootstrapApplication(AppComponent, { providers: [provideAntibot({ apiKey: "pk_live_…" })] })

@Component({ selector: "app-checkout", template: "" })
export class CheckoutComponent {
  private readonly antibot = inject(AntibotService)
  readonly visitor$ = this.antibot.visitor$
  pay() {
    return this.antibot.getData({ ignoreCache: true })
  }
}

Svelte

import { createAntibot } from "@antibot/svelte"

export const antibot = createAntibot({ apiKey: "pk_live_…" })
// $antibot.visitor, antibot.getData()

None of them identify on mount by default. Ask when you need an answer; a hook that fires on every page load bills you for pages where nothing happened. In each, getData() asked before the agent has loaded — a press on a slow connection — waits for it, and a load that failed is that call's error. node check-packages.mjs in sdk/ runs all four against an agent it holds back, so that stays one behaviour.

Cost to your page

The agent runs on your main thread, so its cost is yours: it shows up in your Interaction to Next Paint and Total Blocking Time. Measured in Chrome with the CPU slowed 4x (Lighthouse's mid-range phone), an identification is:

Longest task Total blocking time
Loading the script (evaluating the bundle: about 104 kB gzipped, 86 kB with Brotli, which the agent's route sends every browser that takes it) about 150 ms about 100 ms
First identification in a fresh browser about 90 ms about 120 ms
Every identification after that under 50 ms (none counted as long) 0 ms
The WebRTC check, once per page process: after the first identification, at idle, with no key or button pressed for five seconds (webrtc.when: "identification" puts it inside the identification instead) about 600 ms about 550 ms

How long an identification takes to answer, measured with the server a few milliseconds away (it answers in about 30 ms): in a Chrome that has just started, 0.5 to 0.6 s from the script tag to the answer, and 0.17 s on its next page; in Firefox 0.75 to 0.9 s and about 0.5; in WebKit about 1 s and 0.45. Most of it is the browser warming up — its GPU, its fonts — so load() as the page loads and get() when it is needed, and the first is usually done before anyone presses a button.

Collection hands the main thread back between steps — every font probe of four families, each canvas drawing and its readback, a task of its own — so a tap during an identification is answered rather than queued behind it. The one step that cannot be split is the browser building its first RTCPeerConnection for the WebRTC check — about a sixth of a second on a desktop — so by default that waits until the first identification is over and the visitor has stopped pressing keys and buttons, and never runs inside an identification: an identification made as the page loads, and one made when a form is sent, cost what the table says. The first identification in a browser costs more because the browser itself loads font data and starts its GPU process the first time anything asks; that cost is split across tasks too. Firefox loads a system font asynchronously the first time a page asks for it, so there the fonts not yet seen are measured again 150 ms later (twice, if the first look found more): an identification in Firefox takes that much longer to answer, with the main thread free the while, and reads the same fonts every time. On Windows, a Firefox that has just started finds a font known by an older family name (Arial Black) only once it is next idle, half a second or, when it is busy starting, two; the first page to ask in that browser waits for it, at most 2 s, and every page after has it at once. The speech voices read are the machine's own, waited for until they have arrived: Chrome and Edge announce their cloud voices and their own a few milliseconds apart, and only in a browser that has just started. The integrity key's program is part of the identification and of those figures: laying out its scenes takes about a millisecond (4 ms at CPU x4, 8 at the 95th percentile, on a page of 400 sections), in a box of its own whose layout touches nothing of the page's. Its proof of work is nothing from an ordinary address at a person's pace.

It is a careful guest on your page. Every WebGL context the agent opens is released as soon as it has been read — browsers keep only about sixteen per page and destroy the oldest past that, which would be yours if you draw a map or a 3D view — and it never touches your console.

It does wrap a few of the page's own functions, to count how they are called: fetch, XMLHttpRequest.prototype.open and navigator.sendBeacon (calls made with no person's gesture before them, and calls from a driver's evaluated code — a scraper calling your API through the browser it drives), HTMLCanvasElement.prototype.toDataURL and CanvasRenderingContext2D.prototype.getImageData (canvas readbacks), and history.pushState and replaceState (a single-page app's navigations, for the session pulse). Each wrapper passes every call through to the original unchanged, returns what it returns, and never throws on your page's behalf; but they are wrappers, kept for the page's life — fetch.toString() no longer reads [native code] — which a tool of yours that checks natives would see.

Load it once a page if you can; loading it again costs little. Every agent a page loads shares one set of watchers (the listeners, the hidden tripwire, the wrappers) and the page's one session pulse, which the agent that earned the newest receipt keeps: a framework that mounts twice, or a page that calls load() on every route, adds nothing to the page. Each agent keeps its own cached result. probes/guest.mjs checks all of this in a real browser, against the served bundle — the WebGL contexts, the console, three loads leaving one set of watchers and one pulse.

What it keeps in the browser

For your cookie notice and records of processing: everything the agent writes, each for the protection of the site — none of it read by any other site, none of it used to follow a visitor elsewhere.

Where Name What For how long
cookie, set by the first-party proxy on its identify mount, HttpOnly _abvh the same hint as antibot:visitor, where Safari keeps it: it deletes what scripts write after seven days away, not a cookie the site's own server set; sent back only to that mount; cleared with rememberVisitor: false 400 days
cookie _abid the receipt: the session token the edge and the server middlewares verify ten minutes, the token's own life; cleared when it is revoked
sessionStorage or localStorage antibot:result the cached result, only with cache: "sessionStorage" or "localStorage" the cache's life
localStorage antibot:visitor who this browser was, as the last answer signed it (a visitor id, the environment's signature, a date), sent with the next identification so the browser is compared with its own visitor first; none with rememberVisitor: false until the next answer replaces it; honoured for 400 days (Safari deletes it after seven days with no interaction with the site; behind the first-party proxy, _abvh keeps it)
sessionStorage antibot.gpu the makers of the machine's GPUs as WebGPU names them, asked once a tab, for the session pulse the tab
sessionStorage __antibot_rtt the edge's timing chain, for the next page's identification four minutes
sessionStorage __antibot_webrtc the tab's last WebRTC reading — the public addresses the STUN servers saw it from, which the server compares with the address the identification arrived from — for the tab's next pages, so each does not build a peer connection of its own; none with webrtc: false five minutes
sessionStorage __antibot_driven how many navigations a driver's evaluated code started, for the next page until that page reads it
sessionStorage __antibot_clicks the tab's last eight mouse clicks, each only as on its target's centre or off it — no position, no target the tab
localStorage, IndexedDB __antibot, antibot-probe written and removed at once, to see whether the browser allows storage none

Tamper resistance

The shipped bundle (antibot.js / antibot.esm.js) is minified and then obfuscated: strings live in a rotated, base64-encoded lookup table (detection markers like automation tool names never appear in cleartext) and control flow is flattened through a dispatcher. Dead branches and strings split into chunks are left out: public deobfuscators undo both in one pass, and they were a fifth of the bundle.

It is deliberately not self-defending (code that stops working once reformatted). That stops someone pasting the bundle into a beautifier and nobody else — standard deobfuscators strip it — and it cost every visitor about 100 ms of script evaluation on a mid-range phone. What actually protects the payload is below: a per-deploy MAC computed inside WASM and verified on the server, and four independently obfuscated variants in rotation.

Five small numeric functions are deliberately left unobfuscated: the hash over component values, base64 over the sealed payload, a pixel checksum, the proof-of-work predicate, and the clock-resolution loop. Obfuscated, they made identification several times slower — and a timing loop measures the obfuscator rather than the machine. They are generic arithmetic and reveal nothing about what is collected or how it is judged. The build fails if their markers are unbalanced or if they cover more than a tenth of the code.

On top of that, the three computations an attacker most wants to read are not JavaScript at all. agent/wasm/security.wasm (AssemblyScript, ~5 kB, embedded in the bundle) holds the SHA-256/HMAC implementation, the integrity canonical construction, and the proof-of-work solver. A JavaScript deobfuscator recovers only the wrapper names; the algorithms and constants require wasm tooling.

Per-deploy polymorphism. Every agent:build stamps a fresh random build id and salt into the WASM module (agent/wasm/build-config.ts, generated), feeds the salt into the integrity canonical, and writes the pair to web/.agent-builds.json (gitignored) for the server. The agent sends its build id as integrity.b; the server MACs against that build's salt and accepts the current and previous build so rollouts don't cut off loaded pages. A solver reverse engineered from one deploy stops verifying after the next one — rotation is free because every deploy rebuilds the agent. (To rotate between code deploys, redeploy; the registry is rebuilt each time.) Each instance also publishes its builds to the database (agent_builds) as it starts, before it serves a page, and reads back the deployment's two newest, every thirty seconds and at once on a build it does not know: an instance deployed from a checkout whose file held only its own build still accepts the pages loaded from the last one, and in a rolling deploy an instance not yet replaced accepts the new build's pages — and writes their programs in its dialect. An instance always accepts its own builds, so a rollback's pages are never stale where they are served.

Per-request polymorphism. The build also obfuscates the IIFE bundle four times over with independent seeds (AGENT_VARIANTS to change the count) and GET /agent/v1/antibot.js rotates which variant it serves on an hourly bucket — cacheable within the hour, a different string table, dispatcher layout and identifier names the next. The route is rendered per request (a static one would be rendered once, at build, and serve one variant until the next deploy); a CDN keeps each answer to the end of its hour (s-maxage), so it runs about once an hour behind one. All variants wrap the same WASM, so integrity verification is unaffected; what changes is everything a reader annotated. The ES module (antibot.esm.js) is a static file, one variant for the whole deploy: where the hourly rotation matters, load the agent through loader.js.

A program per identification. Every integrity key comes with a program written for that key alone (lib/vm), encrypted under the key's secret. Its sixteen operations are numbered afresh each time, so the bytes of one program say nothing about the next; it asks the browser a handful of questions drawn at random — the width of a box it lays out in a closed shadow root, whether a DOM method named by the program is native code, whether the engine supports a CSS declaration, how a media query and the viewport agree, how an Error's stack is written, whether a browser's global is there — and folds the answers through a loop of its own into one number. Two or three of its questions are scenes: small layouts the program carries — a block and its auto width, a percentage, a min or max width, a flex line, grid tracks in pixels and fractions, an absolute box between a left and a right — built in that shadow root and measured. Most of them are trees: blocks, flex lines and grid rows nested two deep inside one another, stretched, shared out and placed as a browser does — lines justified, aligned, reversed and reordered, blocks centred by auto margins, heights given by aspect ratios, grids of two rows, boxes placed absolutely out of the flow they are drawn in — which the server lays out with a layout of its own for exactly what it draws (lib/vm/scene-trees.ts). What a scene measures to is written nowhere in the bundle; only the server, which drew it, knows, so answering one without a browser means writing a layout engine. Every scene measures to a whole pixel in every engine, whatever the page does to its own elements: probes/vm-scenes.mjs lays out 86,400 in Chrome, Firefox and WebKit at four device pixel ratios, on pages that set * { box-sizing: border-box }, right to left, zoomed, with a hidden body. The agent runs it in a small interpreter once collection is over and sends the number and the answers; the server writes the program again from the key's id (it keeps nothing), runs it on those answers and checks two things: that the number is the one they give, and that they are what a browser answers — the stack the one of the engine the User-Agent names. An answer cannot be learned once and replayed: each key's program asks other questions, in another order, and combines them otherwise. To get a valid number without a browser, someone has to run the program — and run in jsdom, happy-dom or a Node vm behind stubs it answers as no browser does: nothing is laid out, the methods are JavaScript, there are no media queries. The program can also ask for a proof of work, priced by how much the asking looks like a farm: nothing from an ordinary address at a person's pace; 13 bits (a few milliseconds in the WASM solver) from a datacentre's, or from an address asking for more than ten keys a minute (sixty from its /24); 16 from an address on the public blocklists or past thirty a minute — pace alone never more, since a carrier's NAT puts thousands of phones behind one address, but 18 past sixty under attack mode; and under attack mode at least 16, two more for anyone who would have paid anyway, to a ceiling of 20 — a million hashes, the better part of a second. The pace is counted in the database, so every instance sees it alike. Nothing to a person, and a bill that grows with how hard a farm pushes; a program left unpaid costs ten points instead, or a farm would simply not pay. What the server makes of it is payload_integrity.program and the automation:vm* and automation:emulatedDom:* reasons under bot. Agents from before it ask for a key without vm: true and get none. Asking that way is also what a farm tries next, so the build registry records which builds run programs: a key without one, on a payload a build that runs them signed, is someone driving that build's WASM with requests of their own (missing, conclusive); and once every build being served runs them, a key without one on a path that names no build costs what no key at all does. The registry also records the newest wire each build reads, and no program is written older than the oldest of those, nor older than the first wire that shipped: asking for the first wire, whose questions have the least to them, gets the one every served agent reads.

A dialect per build. Every agent:build also draws the build a VM dialect of its own (lib/vm/dialect.ts): the two bytes its programs start with, the byte each question is asked by, the byte each scene property and measure is written as, the constants the program folds its answers with, and the seed of the keystream it is encrypted under. The dialect is compiled into the bundle and recorded beside the build's salt; the agent names its build when it asks for a key, and the program comes written in that build's dialect — or, for a build no one knows or none named, in the newest build's. What a reader learned of one deploy's programs, down to which byte asks for a box's width, reads as noise in the next deploy's: a solver has to be taken apart again each time the agent is rebuilt, which every deploy does, and which rotating the agent on its own (an agent-only rebuild) does too.

Sealed payloads. On the WASM path the identify body no longer travels in cleartext. The module derives an AES-256-GCM key — HMAC(integritySecret, "antibot-payload-v1\n" + buildSalt) — inside the WASM, and the agent sends an envelope of just the routing and verification fields (apiKey, nonce, requestedAt, checksum, integrity) plus sealed, the ciphertext. The server verifies the envelope MAC first — which also spends the single-use key — and only then decrypts, so the GCM tag is never exercised on ciphertext that did not already prove itself. A proxy log of the request shows nothing about what the sensor saw; reproducing the derivation requires the build's salt, which is compiled into the WASM and never appears in the JavaScript. Nor is it handed out: the module computes the MAC in one call, so no export returns the salt or the salted canonical, and it keeps the salt as two masked byte runs, so its text is nowhere in the module's bytes. A script on the page cannot ask for it; whoever unpacks and instruments the module can read it, which is why every deploy changes it. Agents without WASM send the legacy cleartext form (sealed: false on the payload_integrity signal).

No engine timing. The WASM module still exports timingProbe, a fixed chain of SHA-256 rounds, but nothing calls it. The agent used to compare its cost with a JavaScript loop and read slow wasm beside fast JavaScript as an emulator. Measured warm in real engines, that ratio is the engine: about 100 in Chrome, 1,600 in WebKit and 5,000 in Firefox, whose wasm ran 11 µs a round on a fast desktop — so an honest Firefox on an ordinary laptop crossed the line, while whole-machine emulation slows both alike and never did. Older agents still send wasmTimingAnomaly; the server ignores it.

Fallbacks. An engine without WebAssembly, or a CSP without wasm-unsafe-eval (the setup checklist warns when most of an environment's identifications arrive this way: sealed), falls back to the WebCrypto PoW and the legacy unsalted MAC automatically; the payload simply identifies with the older scheme and the server treats it as unsigned-but-valid, exactly like pre-WASM agents.

Worker consistency and your CSP. The agent starts one short-lived blob: Worker per page to compare its navigator with the main thread's (see tampering). A CSP whose worker-src (or script-src / default-src, when worker-src is absent) excludes blob: stops it: that comparison is skipped, nothing is reported as a finding, and the browser may send one CSP violation report per page load. Add worker-src blob: to have it measured.

Inline styles and your CSP. The agent sets no style attribute and writes no <style> element where the browser can do without: its hidden trap is placed through the CSSOM, and the hold's styles are a constructable stylesheet. A CSP whose style-src refuses 'unsafe-inline' refuses both an attribute and a <style> in a shadow root — and once did both here: the trap stood in view, for a person to click or autofill to fill (and be taken for a bot), and the hold stood unstyled at the foot of the page. An engine without constructable stylesheets (Safari before 16.4, Firefox before 101) still uses the <style> element, and needs 'unsafe-inline' for the hold.

The fallback is cryptographically unforgeable either way — every path still needs a fresh single-use server key per payload — but it is attacker-selectable, so it is priced and watched rather than trusted blindly: the payload_integrity signal records which path each event took (current build −5 suspect points, stale +2, unsigned +3, a failed MAC lands in tampering), and GET /api/management/v1/integrity-stats reports the mix per environment so a farm camping on the stable legacy scheme shows up as a legacy-share spike. Sites that want harder treatment can match products.payload_integrity.path in a rule, or ask about strict mode for sensitive actions.

What all of this buys: reading the sensor is expensive, not impossible, and the parts that matter rot every deploy. The durable boundary stays server-side — payload signing, replay detection, baselines and scoring all assume the client is readable. pnpm agent:smoke rebuilds the bundle and runs it end to end against a stub DOM, verifying the WASM MAC against .agent-builds.json the way the server does; run it after any agent change, because obfuscation bugs appear at runtime, not at typecheck. pnpm test:wasm runs the module's known-answer tests.

Server-side rendering

The agent needs a browser. Under SSR the packages show no visitor rather than throwing during render, and a getData() made there rejects at once rather than hanging a resolver. Call getData() from an event handler or an effect, never during render.