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 firstrequestAdaptertook 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 theproxysignal (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.pushStateincluded) from a Puppeteer or Playwrightevaluate, 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: theato_credentialsignal, 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
chas 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. AvisitorIdon 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 notpassed: you getpassed: 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)solveChallengereturnsunboundstraight 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 forbotfails 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 nodurationMsat 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 getspassed: false, reason: "verdictStands", and a visitor the environment has never identified getsreason: "unknownVisitor". Usechallenge, notblock, 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
failedwith the reason, notpassed. - 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 thebehavioursignal); 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.