← All docs

Smart Signals

Pass extendedResult: true (browser) or read the event from the Server API to get the full set under products.

Client-side detectors report evidence, never verdicts. The server scores it, which is why thresholds can be tuned without shipping a new agent — or, on mobile, without waiting on app review. Every signal is stored with the reasons that produced it.

if (event.products.bot.result === "bad") deny()

Reading a signal

Most look like this:

{ "result": true, "reasons": ["patchedNatives", "vendorMismatch"] }

Some carry confidence (low | medium | high) or an ml_score (0–1).

A few can report unavailable:

{ "result": false, "unavailable": true, "reason": "ipPseudonymized" }

unavailable: true is not result: false. It means the signal could not be computed — with IP pseudonymization on there is no address to derive it from — and reporting false would be a finding nobody made. Branch on unavailable before you branch on result.

Browser signals

bot

{ "result": "bad", "type": "headless-chrome", "reasons": ["userAgent:headless-chrome", "automation:navigatorWebdriver", "headless:uaHeadless"] }

result is notDetected, good or bad; each reason is named as a rule matches it — automation: before what was read of a driver, headless: before a headless browser's marks. good is a crawler you probably want — Googlebot, Bingbot — matched against a table of known operators. bad is automation pretending not to be: navigator.webdriver, ChromeDriver cdc_ properties, Playwright and Puppeteer bindings, headless markers, and behavioural timing that no hand produces. navigator.webdriver read true inside a Worker or a fresh frame counts the same as on the page itself, which catches a stealth layer that rewrote the main thread's copy and nothing else. So does the flag hidden before the page ran (automation:webdriverHidden), the first thing every guide to hiding a driver does. Every engine's navigator.webdriver is the browser's own getter on Navigator.prototype, which inherits from nothing, and it refuses a receiver that is no Navigator from no frame of script: a script's replacement — set there, on navigator itself, or on an object put between the two — is not, even with its toString made to say it is (its text is read only where toString can be believed: a page whose own replacement mangles every native's says nothing), and the browser's own behind a Proxy refuses from the Proxy's trap, whose frame is on the error's stack — or whose emptied stack is. It is read through navigator's own prototype, never the global Navigator, which a page's script can shadow. And in Chrome and Firefox it is in every window, driven or not (Chrome since 89; Firefox since 88, which took away the preference that removed it), so one deleted — as puppeteer-extra's stealth plugin deletes it — is read there too, from Chrome 90 and Firefox 88 (the RegExp d flag, which no polyfill adds; Object.hasOwn would not do, core-js gives it to older Chrome); not in WebKit, whose undriven Safari was not measured. Playwright's own Firefox and WebKit with the flag hidden by a getter scored 7 and 29 before; both are convicted now. Playwright's binding is read by its exact names (automation:playwrightBinding: the function its Firefox and WebKit leave on every page's window, and what its exposeBinding and init scripts add), and convicts alone, where a global that only looks like a binding — a page's __pwa… — waits for a second reading. ChromeDriver's cdc_ keys are looked for on document (older versions) and on window, where current versions keep their saved copies of Array, Promise and Symbol.

A crawler's name is a claim, and it is checked. "Googlebot" is the most copied User-Agent there is, because sites wave Googlebot through. A request claiming a crawler whose operator says how to tell is checked against what the operator publishes: Google, Bing, Apple, OpenAI (GPTBot, ChatGPT-User, OAI-SearchBot) and Perplexity publish their address ranges; Google, Bing, Apple, Yandex, Baidu and Yahoo name their crawlers in reverse DNS, which is confirmed by resolving the name forward again (anyone can put googlebot.com in the reverse record of an address they own). Google's guide also names googleusercontent.com, but every Google Cloud VM has a forward-confirmed name there, so a name under it proves only an address rented from Google: the fetchers Google names under it are checked against its published lists instead. Meta's crawler must come from its network, AS32934. Verified, the reasons say how (crawler:verified:ranges, crawler:verified:rdns, crawler:verified:asn). Contradicted by every method the operator offers, the result is bad, type: "spoofedCrawler", reason crawler:spoofed:googlebot. A list that would not download or a resolver that timed out leaves the claim crawler:unknown and the result good, so the real Googlebot is never turned away over a DNS hiccup; crawlers whose operators publish nothing (Slackbot, Discordbot, ClaudeBot…) read crawler:unverifiable. Only requests that claim a crawler pay for a check, and verdicts are cached. One instance has at most 64 reverse lookups in flight: a claim past them is crawler:pending — crawler:unlisted, a challenge, when the operator's own lists leave its address out — never crawler:unknown, which is let through, so a spoofer cannot buy a pass by keeping the resolver busy; it is checked once there is room. With IP pseudonymization on, no reverse lookup is made — that would hand the address to a resolver — and only the published ranges are used.

Your markup is not automation. HTML exposes named elements as properties: an element's id on window, a form's or image's name on document. So a <form name="driver"> — a driver sign-up page — or an <input id="geb"> puts names from the automation lists on the page without any automation present. The agent counts a name only when a script put it there: an element, a collection of them, or a named frame is the page's own markup and is ignored. (Before this, a driver sign-up form made every visitor to its page bad.)

In-app browsers are not headless. A missing window.chrome beside a Chrome user agent is the old headless shell's tell, and is read on desktop. On Android it is not read at all: Android WebView — the browser inside Instagram, Facebook, TikTok and every mail app — and the regional browsers built on it say Chrome/ and have no window.chrome, because that object comes from Chrome's own browser layer.

A setting is not a headless browser. Two headless reasons together make the result bad, but headless:noPlugins is not one of the two that count: desktop Chrome with Download PDFs chosen over the built-in viewer has no plugins and pdfViewerEnabled false — exactly what the old headless shell shows — and it is a common setting and enterprise policy. It is still listed as a reason. The shell itself is caught by what it has besides: no window.chrome, a software renderer.

The headless reasons: headless:uaHeadless, the page's User-Agent names a headless browser — HeadlessChrome, PhantomJS, SlimerJS — conclusive alone (an Electron app's Electron/ is not one: its window is a person's); headless:missingChromeObject, no window.chrome beside a desktop Chromium's User-Agent (not an app's window, which has none, as Android WebView has none, being the same engine without Chrome's browser layer: one whose User-Agent says Electron/, or — whatever User-Agent the app set, Chrome's own as often as not — one with Node integration whose process.versions names Electron or NW.js beside Node); headless:emptyLanguages, no navigator.languages; headless:noPlugins, shown and never counted; headless:zeroDimensions, a screen 0 wide or high; headless:softwareRenderer, WebGL drawn by SwiftShader, llvmpipe or Mesa off-screen; headless:permissionMismatch, notifications denied while the Permissions API says it would ask. Any two but noPlugins convict together.

Two readings of how the page was driven are listed among bot's reasons and convict nothing on their own or together — the verdict is the automation and headless evidence's: behaviour:instantHeadlessLoad, the page identified within 40 ms of starting while it did not have the focus, and behaviour:untrustedEvents, fewer than half of the page's input events trusted (isTrusted false: dispatched by a script, not delivered by the browser for a person's input).

An agent hosted by something that is not a browser. Minting tokens at scale is cheaper without a browser: evaluate the served bundle in a Node vm sandbox or jsdom, hand it a navigator and a document, and call it. The stubs are the easy part. What they do not cover is the stack: a script evaluated by Node has node:vm underneath it, and a browser's new Error().stack names URLs and never does. The agent reads the stack as it was when the bundle was loaded, because by collection time the code has awaited and an async continuation carries none of its original callers.

Reason
automation:runtimeStack A frame from node: or Deno's ext:, or from jsdom, happy-dom, linkedom or domino. Conclusive: the result is bad.
automation:runtimeGlobals process.versions.node, Deno or Bun is defined on the page. Corroborating only — an Electron shell with Node integration has one legitimately.
automation:driverEvaluation The page was called from a driver's evaluated code: Puppeteer's pptr:evaluate, Playwright's UtilityScript.evaluate, on the stack of a fetch, an XHR, a beacon or a canvas readback (the agent's API census), or of a script's click. A scraper that reuses the browser's session for the site's API — page.evaluate(() => fetch(…)) — or clicks with $eval(sel, el => el.click()) puts it there; no page's own code is named so. Conclusive. Measured with both, behind a clean Chrome identity.
automation:runtimeStackEmpty The stack has no frames at all. Corroborating: it is what Error.stackTraceLimit = 0 produces, the first thing anyone tries after learning the stack is read, and no browser reports one.
automation:vmAnswerForged The integrity key's program (a program per identification) answered with a number its own reported values do not give: computed without running this key's program on them. Conclusive.
automation:vmAnswerMissing The key came with a program and the payload says nothing of it: a payload built by something that took the key and not the program. Or the key came without one, and the payload is signed by a build that asks for every key with one: someone drove that build's WASM and asked for the key themselves, as an agent from before programs asks. Conclusive.
automation:emulatedDom:<probe> The program's questions answered as no browser does: layout (a box laid out at a width that is not the one asked), native (a DOM method that is JavaScript rather than native code), css (no CSS.supports, or a wrong answer from it), canvas (no 2D context), global (a browser's global missing), stack (a stack not written as the claimed engine writes it), scene (one of the program's layouts measured otherwise than it lays out). Three different kinds are conclusive — an emulated DOM fails most at once — and short of three, one corroborates and the rest are shown: a browser can fail two honestly, a frame hidden with display: none and a canvas blocker, say. A box and a scene count as one kind, the same question asked two ways: that hidden frame lays out neither while being a browser in every other way. Native code and the stack are asked of a fresh frame no script on the page has touched, so a page whose measurement tag wraps getBoundingClientRect, or whose error reporter replaces Error.prepareStackTrace, answers as the browser it is (web/probes/hidden-frame.mjs). media (a media query that disagrees with the viewport) is shown and never counted: a phone pinched in can read its viewport two ways.
automation:vmUnpaid The program asked for a proof of work — the address is a datacentre's or on a blocklist, it or its network asks for keys at a farm's pace, or the environment is in attack mode — and the payload brought none that meets it. Never counted as automation — a phone in a background tab can run out of time, and a crawler's renderer pays from its operator's datacentre — but it costs 10 points: the price taken in points when it is not taken in work. The answer is checked first: left unpaid, one its values do not give is still vmAnswerForged.
automation:vmError The agent said it could not read its program, having named a build no instance knows — an old or foreign bundle, whose program was written in another build's dialect. Never counted as automation, but it costs 20 points. Said by an agent handed a program it can read, it is automation:vmAnswerForged instead.
automation:chromeDriverKeys ChromeDriver's cdc_… properties on document or window — current versions leave their saved copies of Array, Promise and Symbol there once they have run a script in the page — or another driver's known keys on document. Conclusive.
automation:automationGlobal A global only an automation framework defines — PhantomJS's callPhantom, Nightmare's __nightmare, Selenium's __webdriver_evaluate and their kind — written by a script, not the browser. Corroborating: beside another automation or headless reason, bad.
automation:seleniumAttributes A webdriver, selenium or driver attribute on the document's root element. Corroborating.
automation:exposedBinding A function a driver put on window: a name beginning __playwright, __puppeteer or __pw, or bindingCall. Corroborating.

This raises the cost; it does not end it. A sandbox that defines Error.prepareStackTrace to return browser-looking frames defeats the first and the third — not the integrity key's program, whose questions are what is laid out and what is native, not what the stack names: in that sandbox it finds nothing laid out and JavaScript where native code should be (probes/emulated-dom.mjs runs the served agent in jsdom with exactly that disguise, and the program convicts it). Every client-side signal is a claim, which is why this one is one reason among many and the server-side signals — the transport, the engine, the visitor's own history — carry the weight.

ai_agent

{ "result": true, "kind": "assistant", "operator": "OpenAI", "verified": true }

A crawler building a training corpus, an assistant fetching a page someone asked about, and an autonomous agent transacting all fail a naive bot check identically — but a publisher wants the first, a retailer wants to serve the second, and only the third should reach a checkout. kind is crawler, assistant, agent or none.

verified: true means the request carried a valid Web Bot Auth signature (RFC 9421) checked against the published keys of an operator this service knows — OpenAI's chatgpt.com, Google's agent.bot.goog, DuckDuckGo's assistbot.duckduckgo.com — or of a payment network that registered the agent. A user agent string is a claim; a signature is not — but a signature is only as good as whose keys it was checked against: anyone can publish a directory and sign with it. One that verifies against a directory nobody vouches for is verified: false with verification_reason: "unknownSignatureAgent", and its signature_agent says whose it was, for a rule that trusts a partner of yours by name. The signature base binds the request's authority (@authority, or the whole @target-uri), so one captured from another site does not replay here.

What is read is what the IETF working group's draft (draft-ietf-webbotauth-httpsig-protocol) has agents send: Signature-Agent as a dictionary keyed by signature label, each signature covering its own member ("signature-agent";key="sig1"), and the bare string earlier drafts defined, which agents still send. A member's type says where its keys are: the agent's directory at /.well-known/http-message-signatures-directory (the default), a JWK Set URI (type=jwks_uri), or an OAuth Client ID Metadata Document (type=cimd, which must name itself as the client). keyid is matched to a key's kid or to its JWK thumbprint; keys past their exp, before their nbf or published for encryption are not used. Every asymmetric algorithm of the HTTP Signature Algorithms registry verifies — ed25519, ecdsa-p256-sha256, ecdsa-p384-sha384, rsa-pss-sha512, rsa-v1_5-sha256 — and a shared-secret one proves nothing about an agent, so it does not. A key found a way not listed here is reported as unchecked (unsupportedSignatureAgentType), never as a forgery.

A verified agent lowers the suspect score — it is the only automation that identified itself honestly. An unverified claim to be an autonomous agent raises it, because that is the class worth impersonating.

An agent that does not say what it is — a framework such as browser-use driving Chrome on a model's decisions — is read like any other automation, by what the browser is and what it does: as shipped, its headless browser is a bot when the page loads; with Chrome's own User-Agent set, the override it uses is (automation:userAgentOverridden); and once it acts, its clicks land on the exact centre of each element (centredClicks) and its keys are held for about five milliseconds (machineDwell), measured (web/probes/browser-use.mjs). Those two still read on a browser whose identity is the person's own, as when an agent is pointed at their Chrome. Stagehand, which drives Chrome through an extension of its own, is the same: its headless browser is a bot as the page loads, and behind a clean identity its fill — Backspace, then the whole text with no key for it — is named (textInsertedWhole, 13 to 53), measured (web/probes/stagehand.mjs).

tampering

{
  "result": true,
  "anomaly_score": 0.7,
  "anti_detect_browser": false,
  "reasons": ["engineNewerThanClaimed"],
  "engine_version": { "claimed": 110, "at_least": 154, "below": null }
}

Patched native functions, user-agent disagreeing with platform, vendor, productSub, Client Hints or stack shape, the transport UA disagreeing with the reported one, and a payload checksum mismatch. anti_detect_browser is set when the combination matches a known anti-detect build rather than casual spoofing.

One page, one navigator. Stealth plugins and anti-detect browsers patch navigator where the page looks — the main thread — and routinely leave the page's other scopes answering truthfully: a dedicated Worker, a SharedWorker and a fresh iframe each have a navigator of their own. The agent asks all four and reports every disagreement:

Reason
workerUserAgent, frameUserAgent The Worker or a fresh frame reports a different user agent from the main thread. Fires alone.
workerPlatform, framePlatform Same, for navigator.platform. Fires alone.
workerLanguages, frameLanguages A different language list. Corroborating.
workerConcurrency, frameConcurrency A different core count. Corroborating.
workerRenderer The Worker's WebGL renderer differs from the main thread's. Corroborating.
sharedUserAgent, sharedPlatform The SharedWorker reports a different user agent or platform. Fires alone. A User-Agent replaced over the DevTools protocol is replaced per target, and a SharedWorker is a target of its own: Puppeteer's setUserAgent and Playwright's context userAgent leave it with the browser's own string — measured on Chrome 155 — and, headless, that string says HeadlessChrome, which is automation:sharedHeadless under bot, conclusive (workerHeadless and frameHeadless likewise).
sharedLanguages, sharedConcurrency Corroborating — and only where the Worker or the frame did not already say the same: an emulated locale, a QA tool's or DevTools', is per target and shows in both workers at once, one cause.

A user agent or platform that differs between scopes has no honest cause, and sets anti_detect_browser. The rest each have an innocent explanation — a privacy mode, a hybrid-GPU laptop — so one alone is silent and two together fire. Where a privacy tool is evident (Brave, Firefox's resistFingerprinting, canvas farbling — see privacy_settings) the soft readings are not counted at all: such tools perturb values on purpose and are under no obligation to do it identically in every scope. The user agent and platform still are.

What a browser cannot say about itself. Some values a browser produces by construction, and nothing honest replaces them. Two are read on the server from what the agent already reports, so they apply to every agent deployed:

Reason
rendererContradictsPlatform Chromium on Windows with a WebGL vendor or renderer that is not Google Inc. (…) / ANGLE (…). Chromium draws every WebGL context on Windows through ANGLE — a hardware GPU, SwiftShader, Microsoft's software rasteriser, a VM's or a remote session's adapter alike — so anything else was typed in. The stealth plugin reports Intel Inc. / Intel Iris OpenGL Engine, a macOS driver, whatever it claims to run on. Not read for Brave, or on other platforms.
webglRendererSplit Firefox whose two WebGL renderer strings disagree. Firefox gives the renderer twice — RENDERER, and the debug extension's UNMASKED_RENDERER — and both are the one sanitised string, ending ", or similar" (measured on Firefox 132); with resistFingerprinting the first is Mozilla and the second is not offered, and with the sanitiser turned off (webgl.sanitize-unmasked-renderer) the second is the driver's own string — neither is read: only two sanitised strings that differ are. Camoufox rewrites the unmasked pair from the fingerprint it draws, below JavaScript, and leaves the other the machine's own ("Radeon R9 200 Series" beside "GeForce GTX 980"): it scored 0 without this, and lands in the challenge band with it. Its fingerprint is drawn at random each launch, so a launch whose GPU falls in the machine's own class leaves the two strings agreeing — 2 launches in 8, measured; speechVoicesReplaced catches those.
speechVoicesReplaced Firefox on Windows whose speech voice list was emptied in sight of the page and then refilled: a voiceschanged event (a trusted one, not a script's) fired while speechSynthesis.getVoices() was empty, and voices were there by the end of collection. Firefox announces its voices once they are there, the whole list each time — measured by default and under fingerprintingProtection; resistFingerprinting lists none and announces nothing. Camoufox replaces the list below JavaScript with one drawn for its profile, and the page sees it happen: an announcement with no voices, then one with its own — 12 launches in 12. It is what catches the launches webglRendererSplit misses (challenged, 54, where they scored 0). Needs an agent from 2026-10-24 or later; not read for Chromium, or for Firefox on Linux or macOS, where it was not measured.
rendererContradictsSystem A WebGL renderer drawn through Direct3D — Windows' own graphics API — under a User-Agent that names another system: macOS, Linux, ChromeOS, Android, iOS. None of them has Direct3D to draw with; a Windows guest on a Mac is Windows, and says so. Camoufox draws its whole fingerprint at random, a Mac's or a Linux box's as often as a Windows one, on whatever machine it runs: on Windows, measured, its untouched RENDERER still says Direct3D11 under "Macintosh" — 79 with this, where it was 54. A User-Agent switcher claiming a Mac from Windows reads the same. And the other way round: Mesa's drivers (llvmpipe, Gallium, radeonsi, nouveau) are Linux's, under a User-Agent that names Windows, a Mac or an iPhone — a scraper on a Linux server wearing a Windows User-Agent draws ANGLE (Mesa, llvmpipe …), which has ANGLE's prefix and so passed rendererContradictsPlatform. Not read for Android or ChromeOS, where some devices do draw through Mesa.
staleGreaseBrand A Chromium 110 or later whose userAgentData.brands decoy is in the pre-103 form (;Not A Brand, Not A;Brand). Since 103 it is always Not + a character + A + a character + Brand. The version is read off the Chrome/ token, so Samsung Internet and others that number themselves differently are judged by their engine.
platformVersionForged A Chromium 95 or later on Windows or macOS whose userAgentData platformVersion is not three numbers. Chromium reports the UniversalApiContract version on Windows (19.0.0 on Windows 11 24H2, 10.0.0 on Windows 10) and the release on macOS (14.5.0). Playwright's User-Agent override writes the hints from the string it was given — Windows NT 10.0 becomes 10.0, Mac OS X 10_15_7 becomes 10_15_7 — and so does the puppeteer-extra stealth plugin's own override. Nothing else writes those, so this one is also reported under bot as automation:platformVersionForged, conclusive.
clientHintsStripped A desktop Chromium 90 or later (Windows, macOS, Linux) whose userAgentData has no brands and no platform. That is what a User-Agent set over the DevTools protocol without its hints leaves — Puppeteer's page.setUserAgent, Selenium's CDP call, undetected-chromedriver — while Chrome itself always has both in a secure context (in an insecure one there is no userAgentData, which reads as nothing). Also reported under bot as automation:userAgentOverridden, conclusive: the one honest way to it is a developer typing a desktop User-Agent into DevTools' network conditions and leaving its hints blank. Not read on Android, where an app setting its WebView's User-Agent is how every in-app browser identifies itself — nor where an app's WebView asks for the desktop site and says Linux or Windows with no Android at all: a touch screen or an ARM Linux navigator.platform underneath is taken for that WebView, and so is a Windows app's WebView2 (the agent reports its chrome.webview bridge) with a User-Agent of its own. A crawler's renderer — a driven Chrome whose User-Agent was overridden to say the crawler's name — keeps the good-bot reading under bot: that override is how it identifies itself, and the name is checked as for any crawler.
clientHintsBlanked A desktop Chromium 98 or later whose userAgentData keeps its brands and platform but answers every high-entropy hint blank — an empty full version list and an empty full version. That is what a User-Agent set on Chrome's command line (--user-agent) leaves, and an override over the DevTools protocol with only the low-entropy metadata: undetected-chromedriver and selenium-driverless take "HeadlessChrome" out that way, and scored 7, an honest browser's score, without this. Chrome on its own never does (measured on Chrome 155: 155.0.8059.80, x86, 64, 19.0.0 without the flag, all empty with it). Also reported under bot as automation:userAgentOverridden. Not read for Brave, which shapes its own hints — known by navigator.brave, not by a brand name a driven browser could write — nor on Android or in an app's WebView, as above. A kiosk launcher that pins Chrome's User-Agent with the same flag reads the same, by construction.
clientHintsFabricated A desktop Chromium 110 or later whose full version list names Chrome (or Chromium, or Edge) as 155.0.0.0 — the reduced User-Agent string's version — where Chrome always gives its build (155.0.8059.80). A tool that sets the User-Agent and builds the hints from that string has no build to give: Pydoll 3.0's apply_fingerprint, which otherwise writes every layer consistently (the header, navigator, the brands in Chromium's own order), scored 7 headless without this, and 0 with its documented profile. Only the Chromium, Google Chrome and Microsoft Edge entries are read: the decoy brand's 24.0.0.0 is Chrome's own, and another vendor's entry (Opera's, Yandex's) is that vendor's to number. Also reported under bot as automation:userAgentOverridden. Not read for Brave, on Android or in an app's WebView, as above. A User-Agent switcher extension that writes the hints from its string the same way reads the same, by construction.
mobilePlatformForged A phone's User-Agent (iOS, iPadOS, Android) over a desktop's navigator.platform. An iPhone's platform is iPhone, an iPad's iPad (or MacIntel with a Mac's User-Agent when it asks for desktop sites), an Android phone's Linux …; Puppeteer's page.emulate and Playwright's device presets change the string and leave Win32 under it. With the automation flag removed they scored 14 and 17 without this; with it and the engine family, 79 (an iPhone on Chrome), challenged; Playwright's Pixel 7, whose preset's User-Agent is also one no Chrome sends (userAgentUnreduced), 94. Read from the platform string, and only for one no such device has (Win32, Linux x86_64 or X11 under an iPhone; Win32 or MacIntel under an Android phone). Chrome's DevTools device mode leaves the same seam by construction — a developer on their own site, challenged rather than convicted: nothing here says automation.
userAgentUnreduced A plain Chrome User-Agent no Chrome sends any more: a full build (156.0.8078.4), or a phone named in an Android one. Since the reduction Chrome's string says 156.0.0.0 (desktop from M101, Android from M110) and Linux; Android 10; K whatever the phone; the build and the model are in the client hints alone. Playwright's Pixel 7 preset says Android 14; Pixel 7 and Chrome/156.0.8078.4, and so do the lists HTTP scrapers pick from (the risk call reads the same, as userAgent:unreduced, 50). Read from Chrome 113; Edge, Opera, Samsung Internet, Electron and an Android WebView add to the string and are not read, and neither is a desktop Chrome whose own hints carry the same build — the reduction turned off by a policy or an embedded Chromium, where a list's string has no hints, or hints written from it.

Any of them sets anti_detect_browser, and each adds to the suspect score on top of it (forged_identity counts them). Run against the real puppeteer-extra-plugin-stealth, the agent had found only the Worker it missed — score 29, the allow band; with the first two it was 79, in the band the documented rule challenges, and with platformVersionForged — its own User-Agent evasion writes one — it is a bot, 100. Given a Chrome User-Agent with page.setUserAgent, as every guide does, plain Puppeteer with the automation flag removed scored 7 until clientHintsStripped; now 100 as well. probes/stealth.mjs runs the real plugin, and that recipe, whenever STEALTH_PATH points at an install of it. Patchright — the Playwright fork built to leave no automation seam, driving the machine's own Chrome — said HeadlessChrome as shipped and was caught by that; given a Chrome User-Agent it scored 7, bot not detected, until platformVersionForged: now 100. probes/patchright.mjs runs it whenever PATCHRIGHT_PATH points at an install.

A patch that made its toString lie. patchedNatives finds a replaced function by what toString prints; a layer built against the published detectors makes that print [native code] — Pydoll 3.0 replaces Function.prototype.toString itself, in every realm it reaches; Crawlee's fingerprint-injector proxies it and rewrites the stacks its traps throw — and these still give it away:

Reason
nativeFrames Called on something it does not belong to, a function the browser implements throws "Illegal invocation" from no frame of its own (a builtin such as Function.prototype.toString, from exactly one); a replacement in JavaScript adds its own frame to the stack. Measured on Chrome 155 for thirty-one getters and methods — navigator, screen, WebGL, canvas, audio, layout, WebGPU's adapter info: 0 and 1 honest; Pydoll's deviceMemory 1, its WebGL getParameter 2 and its toString hook 2. Read where the stack is V8's, the format measured; through the agent's own wrappers, to what they wrap.
receiverRead Called on an object that records being looked at, a native refuses it without reading it; a replacement that wants to pass for the native has to ask what it was called on. Crawlee's fingerprint-injector wraps natives in proxies and rewrites the stacks its traps throw from (so nativeFrames reads nothing), and its toString reads the receiver's toString and prototype dozens of times; Chrome's own read nothing (measured on Chrome 155 for every function nativeFrames checks; Intl.DateTimeFormat's resolvedOptions reads the prototype by its spec and is left out). zone.js — under every Angular app — replaces Function.prototype.toString with a wrapper that reads a mark off its receiver and adds a frame; where there is a Zone, both readings look through its mark to the native it wraps, as they do through the agent's own wrappers. Sentry's browser SDK replaces it too, on every page it runs on (its FunctionToString integration, on by default), reading __sentry_original__ off its receiver and adding a frame — and keeps the original in a closure, with no mark to follow. So a replaced toString is held against a fresh frame's on every function these readings check: one that prints them all as the engine does is a replacement with nothing to hide, and is not read as a patch; a stealth layer's prints its own patches as native, and is.
argumentsUnenforced A WebGL or 2D-canvas method called without its required argument answered instead of throwing the TypeError Web IDL requires of every binding — Pydoll's getParameter() and getExtension() do.
gpuVendorSplit Chrome on Windows whose WebGL renderer and WebGPU adapter name different makers: Pydoll's documented profile claims a GeForce RTX 3080 in WebGL while WebGPU, untouched, offers the machine's amd / gcn-5. Chrome serves both from the GPU its GPU process runs on there; elsewhere a laptop's second GPU could serve one of them, and it is not read. Software renderers are not read, and it is named only beside a patch. The agent asks WebGPU only on a page whose patch hid itself (nativeFrames, receiverRead, argumentsUnenforced), after its frame-rate reading: starting WebGPU's device took 2.7 s in headless Chrome, measured, a cost identification does not take on. A layer below JavaScript (fingerprint-chromium) leaves no patch to find, so with the session pulse on the agent also asks WebGPU once identification is over — once per tab — and the pulse reads the same contradiction there (gpu_contradicts_webgpu, a challenge; gpu_not_apple_silicon, a conviction; see the session pulse).
platformMismatch navigator.platform disagrees with the system the User-Agent names (an iPhone's and an iPad's own platform excepted: their User-Agent says "like Mac OS X").
productSubMismatch navigator.productSub is not its engine's: Chromium's 20030107, Firefox's 20100101.
uaDataMismatch The Client Hints brand list does not corroborate the User-Agent string.
screenInconsistent The window is wider than the screen it is on (outerWidth against screen.width; zoom moves neither).
timezoneMismatch The time zone's name and the clock's numeric offset disagree: a zone spoofed by name with the offset left where it was.
stackShapeMismatch A Chromium whose error stack does not begin with its message, as some spoofers leave it.
mobileMismatch Client Hints' mobile disagrees with whether the User-Agent names a phone or a tablet.
transportUaMismatch The User-Agent the request was sent with is not the one the page reported.
payloadChecksumMismatch The payload's checksum is not the one its component hashes give: edited after the agent wrote it.
payloadIntegrityInvalid The payload's MAC failed — the invalid path of payload_integrity — beside why: payloadIntegrityIncomplete (presented without the nonce, time and checksum it signs), payloadIntegrityUnknownKey, payloadIntegrityExpired, payloadIntegrityWrongOrganization, payloadIntegrityMacMismatch.

Each counts like patchedNatives: a patch alone is a browser extension as often as a scraper, so it sets anti_detect_browser only beside an inconsistency — and gpuVendorSplit, never read alone, is one. With Pydoll's documented profile nativeFrames, argumentsUnenforced and gpuVendorSplit fire, beside clientHintsFabricated: 99, a bot.

The engine against the version it claims. A user agent is one line of text; the JavaScript engine under it is what was actually installed, and what it implements natively is a matter of record — Object.groupBy arrived in Chromium 117, Promise.try in 128, RegExp.escape in 136. The agent reports which of twenty-three such features are native; the server holds the table of when each shipped, so a wrong entry is a server change rather than a new agent. The table was checked against MDN's browser-compat-data, not recalled.

Reason
engineNewerThanClaimed Two or more features that shipped two or more majors after the claimed version are native. The shape of a script with a hard-coded user agent on whatever Chromium its toolchain downloaded last.
engineOlderThanClaimed Two or more features that shipped two or more majors before the claimed version are missing. The shape of a stale automation image carrying a copied, current string.

engine_version says what was found: the Chromium major claimed, the newest feature the engine has (at_least) and the oldest it lacks (below, or null). Either reason fires without corroboration; the slack and the two-feature rule exist so that a release note off by one, a beta channel, or a single feature held back by an enterprise policy never looks like a lie. Past them it is one, and counts in forged_identity — a User-Agent from a list, as Crawlee's drawn fingerprints (Chrome 147 on Chrome 155) and Botasaurus's random one are — without setting anti_detect_browser: the rule templates challenge an engine mismatch and block anti-detect browsers, and a hard-coded old string is the first.

| engineFamilyContradictsUa:claims-<a>-runs-<b> | The engine is not the family the user agent names: chromium, gecko or webkit. Fires alone. |

The engine family. Version is one question; which engine is a harder one to lie about. The agent reads two observations from the fresh frame: whether a stack trace is formatted the V8 way (at fn (url), where SpiderMonkey and JavaScriptCore write fn@url), and whether the names that exist only in Gecko are present — mozInnerScreenX on the window and the -moz- prefix on appearance, neither of which Firefox has ever removed. A stealth build of Firefox can claim to be Chrome in every string and every navigator property, because those are what it rewrites; what it cannot easily rewrite is what its engine is. A Gecko engine claiming Chromium is reported as claims-chromium-runs-gecko; desktop Chrome dressed as an iPhone by a device preset, as claims-webkit-runs-chromium (Safari has no V8 stack). Either sets anti_detect_browser and counts in forged_identity, a statement the claimed browser cannot make.

The comparison only runs in the directions that cannot misfire on an honest browser: a Chromium has a V8 stack and none of Gecko's names, a Firefox does not have a V8 stack, and a Safari has neither. Every browser on iOS is WebKit whatever its name, so the platform decides there — and it is recognised by what only iOS browsers carry (CriOS/, FxiOS/, EdgiOS/, Apple's Mobile/15E148 build token) as well as by the word "iPhone" or "iPad", because iPadOS's desktop mode presents as a Mac. A Chrome/ token on such a device is not a Chromium claim, so neither this check nor the engine-version one applies. EdgeHTML, which carries a Chrome/ token and is not V8, and crawlers are skipped. Deliberately not checked: that a Firefox has the Gecko names. It does, but that rule could only ever accuse Firefox users, and what it would add is catching a WebKit browser pretending to be one.

The features are read from a fresh same-origin frame, not from your page. A site's polyfills (core-js is on a large share of the web) install missing features and wrap native ones in plain JavaScript; read from the page, either would move the answer. A frame no script has touched has neither problem. Only Chromium user agents are assessed — Firefox and Safari have no table yet — and crawlers are skipped, because their rendering engine updates on its own schedule. Anything not assessed has no engine_version field, which means unknown, never agreement. The table currently ends at Chromium 154, so a claim beyond that is only checked from the newer-engine side until it is extended.

Each evidence section is collected on its own, so an engine that lacks an interface (Web Audio, WebGL) costs that interface's evidence and nothing else. A section that throws is a different thing from one that saw nothing: the agent reports it as tamper.evidenceUnavailable, which appears as a reason and adds a little to the tamper score. It is not counted in the anomaly ratio and never convicts on its own: when the tamper section itself is the one that failed it is nearly the only key left, and one hit in one key would read as a ratio of 1.0. The same is reported when the fresh frame cannot be built, because a page that makes iframe creation throw — or contentWindow read as null — would otherwise erase the engine block and every frame reading without anything for the guard to see. Without this, making one detector raise, or suppressing the frame, would turn a whole section into "clean".

A scope that cannot be measured is unknown, never agreement and never a finding. The Worker is started from a blob: URL, so a page whose CSP has a worker-src without blob: skips it (the browser may log one CSP violation per page load); add worker-src blob: to have it measured. webdriver read as true in either scope is reported under bot as automation:workerWebdriver or automation:frameWebdriver, and is conclusive — the main thread's copy of that flag is the one a stealth layer rewrites first. So are client hints the page has and a scope does not (automation:workerHintsStripped, automation:frameHintsStripped): a User-Agent replaced without its hints empties userAgentData in every scope at once, and brands on the page above an empty Worker were written back by script. Crawlee's fingerprint-injector — Puppeteer's setUserAgent, then its own brands on the page — scored 29 without this, the allow band, on all three launches measured; Chrome's own Worker always has its brands.

virtual_machine

Hypervisor renderers are conclusive. Software rasterisers are circumstantial — common on machines with hardware acceleration off or a broken driver — so they score below the threshold and need corroboration from core count or framebuffer resolution.

The reasons: hypervisorRenderer, a renderer only a hypervisor's virtual GPU names — VMware, VirtualBox, Parallels, Hyper-V, QEMU, Apple's paravirtual GPU, virgl; softwareRenderer, SwiftShader, llvmpipe, Microsoft Basic Render, Gallium or Mesa off-screen; noWebgl; lowCores, two cores or fewer; unusualResolution, a hypervisor's default framebuffer (1024×768, 800×600, 1280×1024, 1152×864); noBattery, Chrome on Windows without the Battery API, which every Chromium ships (Firefox, which removed it, and Brave, which withholds it, are not read).

remote_control

{ "result": true, "confidence": "high", "reasons": ["remoteDisplayAdapter"] }

The session is driven through RDP, Citrix, VNC, TeamViewer, AnyDesk or similar. Legitimate for IT support and shared workstations, and a staple of refund fraud and of an account takeover walked through by a caller — so it is reported separately from automation rather than folded into bot.

Only the display adapter is conclusive (remoteDisplayAdapter). Frame cadence (lowFrameRate), reduced colour depth (reducedColourDepth) and a coarse pointer on a desktop (coarsePointerOnDesktop) each describe an ordinary machine at least as often as a remote one, so two must agree before it fires.

lowFrameRate means the display delivers frames slowly all the time: no two frames arrive within 40 ms, and at least three slow gaps were seen that nothing on the main thread explains (where the browser reports long tasks, a gap that overlapped one is set aside — that frame was late for a busy page, not a slow display). A machine that is merely slow, or busy loading the page, is not reported; a remote session's steady 5–15 fps is.

network

{
  "result": true,
  "available": true,
  "reasons": ["missingFetchMetadata", "transportDisagreesWithUserAgent"],
  "ja4": "t13d1516h2_8daaf6152771_e5627efa2ab1",
  "ja3": "769,47-53,0-10",
  "alpn": "h2",
  "tls_version": "TLSv1.3",
  "http2": "1:65536;4:6291456;6:262144|15663105|0|m,a,s,p",
  "inferred_client": "http-library"
}

The only signal here that does not depend on the visitor's machine telling the truth. Everything else runs as JavaScript on a device the visitor controls, so every value it reports is a claim. The TLS handshake and the HTTP framing are produced by their network stack before any of our code runs.

A script can make navigator.userAgent say Chrome 131 with one line. Making its TLS handshake and header emission look like Chrome's means changing its network stack. The gap between the two is what this reads.

Reason
missingFetchMetadata Claims Chromium, but sec-fetch-* is incomplete. Page JavaScript cannot set these.
missingBrowserHeaders No accept, accept-language or accept-encoding.
missingClientHints Claims Chromium 92 or later, over a connection a browser sends Client Hints on, with no sec-ch-ua. Supporting only: it counts toward a finding when another observation agrees, but never makes the request look like it came from an HTTP library (inferred_client) and never corroborates that guess.
clientHintsContradictUa:<what> sec-ch-ua, -platform or -mobile disagree with the user agent: version, platform, mobile, joined by + when several do. Fires alone.
libraryHeaderOrder:<lib> Header emission order matches curl, python-requests, Go or Java, not the claimed browser.
alpnDowngrade Claims a modern browser, negotiated HTTP/1.1. Not on a WebSocket upgrade, whose connection a browser opens offering HTTP/1.1 alone.
staleTlsVersion Claims a modern browser, negotiated TLS 1.2 or older.
ja4ContradictsUa:<family> The JA4 summary (cipher/extension counts, first ALPN) of a TCP handshake sits outside the envelope any version of the claimed browser family ever shipped. Every browser on an iPhone or iPad — Chrome (CriOS), Firefox (FxiOS) and Edge (EdgiOS) there included, an iPad asking for the desktop site too — is Apple's TLS stack and is held to Safari's envelope. An HTTP/3 (QUIC) handshake — TLS 1.3's three ciphers, ALPN h3 — is not read against it, and on a WebSocket upgrade HTTP/1.1 alone is a browser's offer. Only Chrome, Edge, Firefox and Safari are held to an envelope here; any other browser is held only to offering at least eight ciphers and five extensions (ja4ImplausibleForUa, which needs another observation beside it). A receipt's handshake binding at the edge uses the same envelopes and reads the User-Agent the same way, Opera, Samsung Internet and other Chromium browsers to Chromium's.
ja4AlpnMismatch The connection speaks HTTP/2 or HTTP/3, which the TLS hello did not offer (JA4's ALPN is HTTP/1.1, or none): a server picks from what is offered, so something rewrote one of them. A hello offering h2 first answered in HTTP/1.1 — any server without h2 does that — is alpnDowngrade, an observation.
http2ContradictsUa:<family> The HTTP/2 prelude (settings, pseudo-header order) belongs to a different stack than the user agent claims.
forbiddenH2Header A connection header on an h2/h3 connection — forbidden by the protocol, never sent by a browser.
transportDisagreesWithUserAgent The transport says HTTP library, the user agent says browser.

Two reasons are required to fire — unless one of them is condemning. One observation is not a finding, and an unusual-but-honest client — a corporate proxy that strips headers, a middlebox that downgrades ALPN — should not be accused. The exception is a contradiction the browser itself never produces: the protocol-level ones (ja4ContradictsUa, ja4AlpnMismatch, http2ContradictsUa, forbiddenH2Header), which only ever come from transport evidence the edge vouched for, and clientHintsContradictUa, two things one client said in one request disagreeing. Each fires alone.

The envelopes, measured. A family's envelope is what its TLS stack sends, first connection and resumed (which adds pre_shared_key). On 2026-10-29: Chrome 155 t13d1517h2 and t13d1518h2, Edge and Brave t13d1516h2 and t13d1517h2, Firefox 159 t13d1517h2 both; and the libraries Node's fetch t13d5911h1, Python's requests t13d1812h1, curl over Schannel t13d2012h1, Go 1.27 t13d1312h2. Each envelope stands two ciphers and four extensions from its browsers, where the libraries still fall outside, and a weekly run on Chrome and Firefox stable and beta (web/scripts/tls-envelopes.mjs) fails while a release is still two from an edge — before one extension more in a browser release made its visitors a contradiction. It reads their HTTP/2 preludes too, which http2ContradictsUa holds to exact settings and pseudo-header order: Chrome, Edge and Brave 1:65536;2:0;4:6291456;6:262144|15663105|…|m,a,s,p, Firefox 1:65536;2:0;4:131072;5:16384|12517377|…|m,p,a,s — and Go's 2:0;4:4194304;5:1048576;6:10485760|…|a,m,p,s, which is neither.

A browser behind a TLS-inspecting middlebox — a corporate gateway, an antivirus scanning HTTPS — arrives on the middlebox's connection, not its own: its handshake (and often TLS 1.2 and HTTP/1.1) is the middlebox's, its headers the browser's. Where it was measured, 4–11% of connections were intercepted, Chrome on Windows most of all (Durumeric et al., The Security Impact of HTTPS Interception, NDSS 2017). So in a request assessment the readings of the connection — ja4ContradictsUa, ja4ImplausibleForUa, ja4AlpnMismatch, http2ContradictsUa, alpnDowngrade, staleTlsVersion, and transportDisagreesWithUserAgent drawn from them — count as one finding however many fire, worth a challenge (50): the browser behind the middlebox passes it, and its receipt carries it past the rest (it is marked as minted behind one, tf, and never held to the envelope); a library in a browser's clothes does not. A datacentre address beside them is weighed as the middlebox's address (15), not a browser's own (30). Only what the request itself says beyond them — headers missing, hints contradicting the user agent — adds to the finding.

Client Hints. A Chromium says what it is twice — in the user agent and in sec-ch-ua — and a client that copied one string without the other gives itself away. The common ways to get it wrong are a hints header lifted from a different Chrome version, or from a different operating system; both read as clientHintsContradictUa. A bare setUserAgent in an automation library sends no hints at all, which reads as missingClientHints — soft, because a privacy extension or a site's own Permissions-Policy can suppress them for an honest browser, and only judged where a browser would have sent them (https, or localhost). Three things are deliberately left alone: crawlers, which declare themselves; a phone's "request desktop site" mode, which rewrites the user agent and not the hints, so Linux and Android count as one family and a desktop user agent over mobile hints is never a finding; and anything that is not Chromium.

An honest client is never flagged. curl saying it is curl produces nothing here; that is what bot.result is for. This signal is about disagreement.

available reports whether TLS was observable at all. Header shape works everywhere with no setup; TLS and ALPN need an edge that saw the handshake, so they arrive through the CDN proxy and are honoured only under a valid proxy secret — the same rule the forwarded client IP follows, for the same reason. Without the proxy you still get the header checks, and available is false rather than the signal pretending it looked.

network_change

{ "result": true, "reasons": ["movedToDatacenter"], "current_asn": "AS14618", "known_asns": ["AS7922"] }

The visitor arriving from a network it has never used before, weighed against its own 30-day ASN history rather than a global rule — the check that catches a session hijacked from a home connection reappearing in a datacenter. Needs at least three prior events spread over a week before it can fire, and mobile carriers are exempt, because phones legitimately roam. A move onto a public VPN's exit or Tor's is a datacentre too, and is reported so, but it weighs in the suspect score as any new network (5), not as a move into a datacentre (12): it is the VPN switched on, which vpn (or tor) already counts. With IP pseudonymization on there is no address to build history from, so the signal reports unavailable rather than a finding it cannot make.

incognito

Per-engine: Chromium quota heuristics, WebKit IndexedDB and WebSQL probes, Gecko service-worker absence.

Read this as a weak signal, and on Chromium as no signal. Browsers close these gaps on purpose — a private window that could be told from a normal one would defeat the point of having one — so every probe here is a heuristic that ages out. Measured in Chrome 155 against a genuine off-the-record context (the kind a private window is), the Chromium evidence is identical to a normal profile: the same storage quota, nothing blocked. A Chromium private window is therefore not detected today, and incognito: false from a Chromium browser does not mean the visitor is not in one. The probes can still fire on engines whose private mode is more visibly restricted, which is why they stay, but nothing should be built on incognito alone; it adds five points to the suspect score and no more, and a rule that treats it as a verdict will be wrong in both directions.

The reasons name the engine and the probe: chromium:lowStorageQuota (a storage quota below a normal profile's) and chromium:storageBlocked (localStorage refused); webkit:indexedDbBlocked, webkit:webSqlBlocked and webkit:storageBlocked; gecko:noServiceWorker and gecko:indexedDbBlocked; and, from any other engine, generic:storageBlocked (localStorage or IndexedDB refused).

vpn, proxy, tor

vpn combines an IP-versus-device timezone mismatch (the browser's zone, or the phone's), public VPN ASNs, relay egress and a phone on a datacentre's address (os_mismatch), and reports which methods fired. A desktop on a mobile carrier's address is not one: a laptop tethered to its phone, or a home on 5G or fixed-wireless internet, is that. A zone travels with its device — a phone abroad whose data roams home disagrees with its address as much as a VPN does — so the mismatch corroborates and never names a VPN alone. proxy classifies datacentre and residential-proxy ASNs, and reports its methods (residential_network, datacenter, webrtc_leak, relay_latency). tor checks a live exit-relay list, cached six hours.

The WebRTC leak. A residential proxy's exit is a real household's address — nothing about the address gives it away, which is what such networks sell. The browser behind it does: a proxy carries HTTP, and WebRTC goes around it. The agent asks a STUN server which public address its UDP path comes from (a server-reflexive candidate; no media, no permission prompt, a second at most), and the server compares that with the address the identification — or the session pulse's beacon — arrived from. By default the agent asks after the page's first identification, at idle and with no key or button pressed for five seconds, off identification's path (a page's first RTCPeerConnection is one long task): an identification made as the page loads goes without it, the pulse's next beacon carries the reading (webrtc_leak, +15, sticky), and the tab's identifications include it for five minutes; webrtc: { when: "identification" } asks inside every identification instead. Measured in web/probes/webrtc.mjs against a STUN server on the machine: the pulse names it, the next page names it, the inline first identification names it, and at CPU x4 the identification made as the page loads has a longest task of 185 ms by default against 662 ms inline. A reflexive address in another network sets webrtc_leak and adds 0.6 to proxy, enough for result: true on its own. What is not a leak: an IPv6 privacy address rotating within its /64, a dual-stack browser that reached STUN over the other family (only the request's family is compared), and a carrier NAT pool giving UDP and TCP different addresses (the same IPv4 /16 or IPv6 /32 counts as the same network). A request from a private address, or a browser with WebRTC blocked, compares nothing and claims nothing. On the server the reflexive address is compared and dropped: never stored, looked up or returned — it is exactly what a VPN or proxy user meant to hide. In the browser, the tab keeps its last reading five minutes in its own sessionStorage (__antibot_webrtc), so its next pages need not build a peer connection each. Turn the check off with webrtc: false, or point it at your own STUN server, in the agent's options.

The browser's distance. Behind the first-party proxy on a platform that reports the connection's round trip (Cloudflare; the forward-auth service behind nginx), the proxy times the browser itself, and a proxy that terminates TCP — a residential pool's exit — puts the browser further away than the connection that reached the edge. The chain the tab's last page timed rides its next identification, and a browser 150 ms and three times further than its connection sets relay_latency and adds 0.45 to proxy; 400 ms and four times, 0.6, enough for result: true on its own. The method is present, true or false, whenever a timed chain came with the identification, and absent otherwise — on a tab's first page, without the proxy, or when the agent did not send one (a page too busy to answer its pings promptly). The same reading moves the session pulse (rtt_beyond_connection). People sit behind such proxies too — a corporate gateway far from them, a geostationary satellite link's accelerator — which is why it weighs as the WebRTC leak does: a proxy, not a bot.

How a network is classified. By AS number first — a table of the major clouds and of the hosts bot traffic comes from, including those whose registered name gives nothing away (Vultr registers as "The Constant Company"; IONOS, netcup, Gcore, Selectel, Aeza, Stark Industries) — and only then by the operator's name, matched as whole words. A consumer privacy relay — iCloud Private Relay's egress (Akamai, Cloudflare, Fastly) and Cloudflare WARP — is its own class: a person using a feature of their phone or browser, read as relay egress by vpn, never as a datacentre by proxy. The classification is applied again whenever a cached lookup is read, so a correction reaches addresses already cached.

All three report unavailable under IP pseudonymization — except a WebRTC leak, which is a comparison of two addresses in memory and still sets proxy.

network_reputation

{ "result": true, "level": "medium" }

What other organizations' own verdicts said about this address over the last two days. One site sees an attacker once; across many, the same addresses come back. level is none, low (one other organization), medium (two to four) or high (five or more); result is true from medium up. It adds 8 (medium) or 15 (high) to the suspect score — corroboration, never a verdict on its own, because addresses are shared and a household behind a carrier NAT can inherit a neighbour's record. In a request assessment, where there is no browser to look inside, it adds 20 or 35.

What counts, and who: only high-precision verdicts (automation, a honeypot, a score of 80 or more, an assessed request scored 80 or more on its own merits whatever blockAt you send, a trap link followed — never a VPN, a proxy, your own filters or rules, or a block your own lower threshold made), and only from organizations on a paid plan, because anyone can open a trial and a tenant can name the address an assessed request came from. Your own verdicts are left out of what you read. What is kept is a keyed digest of the address (its /64 for IPv6), the organization and the day — not the address — and rows go after a few days. You learn a level, never which organizations or how many. Turn it off under Settings → Network reputation; it is off, in both directions, while IP pseudonymization is on (unavailable, notParticipating).

ip_info

Geolocation, ASN, hosting provider, plus impossible_travel and impossible_travel_km against this device's previous sighting — between addresses of its own only. A Tor exit, a VPN's, a privacy relay's or a datacentre's says where the exit is, not the device: a person switching their VPN to another country's server, or Tor drawing a new circuit, is not travelling, and such a sighting neither is measured nor becomes the place the next one is measured from.

ip_blocklist

Membership of email-spam, attack-source and Tor-exit lists, with the details broken out. An address on the customer's own threat feed (IP, containing CIDR, or matching ASN) counts as attack_source: the integrator curated the list, so a hit is their assertion about the address.

{ "result": true, "details": { "email_spam": false, "attack_source": true, "tor_exit_node": false }, "lists": ["spamhaus-drop", "ipsum:7"] }

Public lists, on your server. With ANTIBOT_BLOCKLISTS=fetch (the service fetches them itself, once a day for the deployment) or ANTIBOT_BLOCKLISTS_DIR set (filled by pnpm blocklists <dir>, daily), two public sources are read from files and every address is looked up in memory — never sent anywhere — and lists names the ones that hold it:

List What it holds Counts as Suspect score
spamhaus-drop Spamhaus DROP: netblocks hijacked or run by criminals, which no reputable network routes (about 1,700 IPv4, 90 IPv6) attack_source +20 (+40 in a request assessment, ip:spamhausDrop)
spamhaus-asn-drop Spamhaus ASN-DROP: networks (AS numbers) run by criminals (about 440) attack_source +20 (+40, ip:spamhausAsnDrop)
ipsum:<n> IPsum: IPv4 addresses on n of 30+ public lists of attack sources — scanners, brute-forcers, botnets — named from n = 3 (about 20,000 addresses); 6 or more weigh more (about 2,000) attack_source +6, or +12 from six (+12 / +25, ip:blocklisted:<n>)

IPsum is corroboration, not a verdict, and is weighed that way: measured against DB-IP's networks, its addresses are mostly internet-wide scanners (Censys, ONYPHE, Driftnet — now classified as hosting too), clouds and servers, but some are households' — a router taken over, a carrier NAT shared with one — so a count stays below a challenge on its own. A DROP netblock is not somewhere a person browses from. email_spam is not filled from these: no list of spam sources found was free for a commercial product. IPsum is released under The Unlicense; Spamhaus DROP is free for any use with credit — "Spamhaus DROP data, The Spamhaus Project" — and asks to be fetched no more than once a day, which the script keeps to. Unavailable, like every address reading, under IP pseudonymization.

privacy_settings

Brave (brave), Firefox resistFingerprinting (firefoxResistFingerprinting), canvas farbling (canvasFarbling), clock coarsening (reducedTimerPrecision), Global Privacy Control (globalPrivacyControl), Do Not Track (doNotTrack). Clock coarsening is a clock coarser than browsers ship with: resistFingerprinting's 16.67 ms, Tor Browser's 100. Firefox and Safari read the time to 1 ms as they ship, and are not named for it. Privacy tooling is not fraud — this exists so you can tell "unusual" from "hostile", and so a low confidence score has an explanation.

People who harden their browser are people, and what hardening changes is read so as not to take them for bots:

  • Brave, Firefox's resistFingerprinting, canvas farbling change values on purpose, and not always alike in every scope of a page. Where one is evident, the cross-scope readings it can make differ innocently are not counted as tampering; a missing Battery API counts only for a browser that claims Chromium and is not Brave. This signal names the tool and adds nothing to suspectScore.
  • No WebAssembly — Safari's Lockdown Mode, or a page whose CSP lacks wasm-unsafe-eval: the agent does its proof-of-work and its integrity check with WebCrypto instead (fallbacks), payload_integrity reads that as valid, and a challenge is passed the same way. The program the server has the agent run asks nothing that WebAssembly or WebGL answers.
  • No WebGL — switched off by a policy or a privacy mode: one corroborating reading of virtual_machine, a quarter of what it takes to fire, never a finding on its own.
  • Tor Browser: its exit is named (tor, +25 to suspectScore) and that is all it costs by itself. At its Safest level JavaScript is off, and no agent — this one or any other — runs at all.
  • Global Privacy Control, Do Not Track: named, nothing more.

developer_tools

Whether a panel is docked to the window and has taken room from the page.

There is no console probe. The well-known one — a getter on a logged object that fires when an inspector formats it — fires in current Chrome with no inspector and no debugging port at all, so it was true for every visitor. It was measured, not assumed, and removed.

Browser zoom changes the viewport in CSS pixels and nothing else, so "the window is much bigger than the viewport" is true for anyone zoomed in to 120% or more. What a panel does that zoom cannot is take room from one dimension: zoom takes the same fraction from both (a little more from the height, where the toolbar is), while a panel docked to the side takes width and no extra height, and one docked to the bottom takes a third or more of the height beyond what the width lost. Each delta is read as a fraction of its window and only that asymmetry counts. A small panel — under about a fifth of the window to the side, a quarter at the bottom — is missed on purpose: this signal is informational, and a detector that accuses people who zoomed in is worse than one that misses a narrow inspector. Zooming out makes both deltas negative and can never match.

What the rule cannot do is tell an inspector from any other panel docked inside the browser window. A side panel — Chrome's reading list or bookmarks, Edge's Copilot or sidebar, Firefox's sidebar — takes room from the width and not the height in exactly the same way, so a visitor with one open reads as having developer tools open. The old absolute check did too. It is informational, adds four points, and should not be acted on by itself.

velocity

Distinct IPs, countries, linked ids and event counts over 5 minutes, 1 hour and 24 hours, per device.

{ "distinct_ip": { "intervals": { "5m": 1, "1h": 3, "24h": 11 } } }

Works under IP pseudonymization: it only ever asked whether two requests came from the same source, never which one, and a keyed digest answers that exactly as well as an address.

A source is an IPv4 address or an IPv6 /64 — the block an operator hands one home line, phone or server; the host picks the rest. A phone's privacy extensions rotate the low bits on their own, so counting addresses made one handset several "IPs" in an hour; and a server rotating through its own /64 is one source, not thousands. Every per-address rate limit counts the same way. (Where addresses are not kept, only digests of full addresses exist, so distinct_ip there still counts addresses.)

A privacy service's exits are not the device's own: an identification through Tor, a consumer VPN or a privacy relay — iCloud Private Relay rotates its users' egress on its own — counts as an identification, but adds nothing to the device's distinct_ip or distinct_country. The service changing them is the Tor, the VPN or the relay again, which vpn and tor count once. A datacentre's addresses are counted: a script rotating through a cloud's is what these are for.

Each count reads the device's (or the source's) most recent 5,000 events of the window and stops there: the history is read on every identification, and a farm of identical browsers is one device, so an unbounded count made each of its requests cost all the ones before. Every threshold a signal or a template rule draws is in single or low double digits; a 24h count of 5,000 means "at least". The same cap bounds high_activity_device's daily count and the networks network_change compares against (a device that busy returning to a network it last used before its 5,000 most recent events reads as new to it).

rare_device, high_activity_device

Share of your traffic with this device configuration, and 24-hour request percentile.

replayed

{ "result": true, "reasons": ["nonceReused"], "clock_skew_ms": 412 }

A captured payload resubmitted. The fingerprint is perfectly valid — that is the point of the attack — so what gives it away is a one-time nonce arriving twice (nonceReused), a timestamp outside the five-minute window (staleTimestamp), or the exact same payload hash seen before under a different identity (payloadReused). An agent old enough to predate replay protection sends neither field, and absence is reported as unknown rather than as a detection.

api_abuse

{ "result": true, "reasons": ["callsWithoutGesture"] }

The agent passively wraps fetch, XMLHttpRequest, sendBeacon and the two canvas readback APIs, and counts how many calls happened without a trusted gesture (pointer, key, touch) in the five seconds before. A person clicking through a shop produces calls with a gesture just before them; a script driving the page calls away while nobody touches anything. Gesture correlation is a property of the session, not of any script's identity, so it survives renaming, eval indirection and service-worker proxies. Our own collection is excluded from the census, and a missing apiActivity field — old agents — scores nothing.

Two more shapes are named. canvasReadbackBurst: more than twenty canvas readbacks — fingerprint probing or data extraction, not drawing. immediateApiActivity: the page's first call within 50 ms of its start, before anything was painted — a collector, not a visit.

honeypot

{ "result": true, "kind": "field" }

The agent plants an off-screen, untabbable, aria-hidden link and input with randomized names. Nothing human can reach them; a DOM walker, a form autofiller or a scraper enumerating anchors touches one. Near-conclusive, and weighted accordingly.

behaviour

{ "result": true, "reasons": ["constantVelocity"] }

The last 128 pointer positions, judged for what a hand does: acceleration, overshoot, course corrections. A synthesised glide at constant velocity (constantVelocity), a teleport across the screen (pointerTeleport: more than 600 px between two samples), or thirty samples without a single turn (noCourseCorrections) are script shapes. Corroborating evidence only — assistive tools and trackpads produce odd-but-honest paths — so the weight is small.

One shape is not small: linearTween, a path in straight legs of equal steps — every step within 8% of one length (or twice it, where the agent's 16 ms sampling dropped a point), every point within 1.5 px of the leg's line, eight steps or more, twice or one leg of twenty. That is a script going from element to element, mouse.move(x, y, { steps }), and no hand draws it. Measured with Puppeteer behind a clean Chrome identity: nothing said and score 25 before, 57 now. It weighs 40 — a reason alone, a challenge beside anything else — and not more, because the keyboard's MouseKeys moves the pointer in fixed steps too, for people who cannot use a mouse, and they pass a challenge.

Nor is centredClicks: every mouse click — two at least — landing within a pixel of the centre of what it hit (the element or its parent; boxes under 16 px a side or 600 px² are not read). Puppeteer's page.click, Playwright's locator.click, a WebDriver click (the spec's "in-view centre point") and nodriver's mouse_click all land there, measured; a hand lands anywhere on the box, on the centre now and then and never every time. A click needs no pointer path, so it is read without one. Measured with Puppeteer behind a clean Chrome identity, flag removed and Chrome's own hints: 7 before, 47 now; it weighs 40, like linearTween, and not more: a screen reader's mouse-click command (NVDA's) moves the real pointer to the centre of the object it clicks, and its users pass a challenge. (A screen reader's ordinary activation is a click with no pointer type, and is not measured; neither is a keyboard's.) The session pulse reads it per window too (centred_clicks, +20, sticky), for the scraper that walks a listing a click at a time. The tab keeps its last eight clicks for its next page, which counts them with its own: a scraper paging through a list by its Next link — one click a page, never two on any — is read from its third page. Measured: Puppeteer's and Playwright's click() on three pages' links, named on the fourth; a hand's clicks on the same links, off their centres, not.

Nor is jumpedClicks: every mouse click — two at least — reached by a jump. The agent counts the pointer samples between where the page last saw it (the last click, or its first move on the page) and the next click 40 px or more away, every sample a frame merged included (getCoalescedEvents). A hand's reach of 40 px takes a tenth of a second at least and several frames' samples however fast it is; a driver's mouse.move, and a computer-use agent's — a model shown screenshots, acting through xdotool mousemove and xdotool click, as Anthropic's reference computer use does, or pyautogui — arrive in one, and click wherever the model put the point, on the centre or not. Where the pointer may have moved out of the page's sight it is forgotten, and the next click not read: leaving the page or entering a frame, the window losing focus or the tab hidden, a zoom, a drag, a touch or a pen, and the controls with a popup of the browser's own (a select, a date or colour picker, a file dialog). Weighed 40, like centredClicks, and for the same reason: a screen reader's or a voice control's click moves the real pointer in one step, and its users pass a challenge. Measured behind a clean Chrome identity, the events dispatched as the system delivers xdotool's: 7 before, 57 after a sign-in (web/probes/computer-use.mjs); a hand's sign-in, and a hand that leaves the page twice and clicks where it comes back in, stay at 7. The session pulse reads it per window (jumped_clicks, +20, sticky), for the agent that acts after the page identified.

Nor is textInsertedWhole: a field's text arriving whole — one trusted insertion of a word or more (three letters or digits at least), no key that writes pressed in that field in the second before it, no input method composing. A key that writes is a character's, a dead key's or an input method's (AltGr's characters among them); Backspace, Delete, the arrows and a shortcut such as Ctrl+A or Cmd+A write nothing — Stagehand's fill presses Backspace in the field before inserting the whole text, and select-all before it is the other way to clear one. That is the DevTools protocol's insertText, which Playwright's fill(), Crawl4AI and most agent frameworks type with. Measured on Chrome 155, nothing a person does looks like it: keys insert a character at a time; Chrome's own autofill sends a plain input Event and no beforeinput; a writing aid's execCommand sends no beforeinput either; an input method composes; a paste says so in its inputType. Read on a desktop only — a phone's keyboard commits its predictions in ways not measured here — and weighed 40, a challenge beside anything else, never a conviction: dictation software may insert a phrase the same way. Measured behind a clean Chrome identity: 19 before the fill, 59 after. The session pulse reads it per window too (text_inserted_whole, +20, sticky), for the form filled after the page identified.

The driver's evaluated code is read on navigation too: a page left by page.evaluate(() => location.href = …), or by a link's click() from there, dispatched its navigate event (the Navigation API, Chromium's) inside that call, with the driver's frame on its stack, and the tab's next page says so in its first identification — automation:driverEvaluation, as for a fetch from the same code (measured: Puppeteer's pptr:evaluate, Playwright's UtilityScript.evaluate; a click on the link, a person's or the driver's own, starts no such call). A navigation within the page — history.pushState from the same code, a single-page app's route — is counted where it is, and the session pulse carries every such call made after the page identified (driver_evaluation, +70, sticky, a block): a single-page app identified once and driven after is convicted without another identification. Measured with Puppeteer: a pushState and a fetch from its evaluate, blocked on the first beacon; the same session driven only by its clicks, never so.

Nor is navigationsTyped: where the edge places the agent on every page, it says on the tag how each page was asked for (its Sec-Fetch-Site), and the API counts a device's arrivals over ten minutes. Measured on Chrome 155: a link clicked, a form sent, a reload, back and forward all arrive same-origin; an address typed and a driver's page.goto — the DevTools protocol's navigate — arrive none. Eight pages or more, every one none and none by the site's own links, is a scraper working through a list of addresses: from JavaScript it is otherwise the browser it drives. Weighed 40, on a desktop only — a phone opens the links other applications hand it the same way — and never a conviction: a person opening a newsletter's links one after another, or a browser restoring a session of the site's tabs, arrives that way too. Measured end to end: ten products loaded by goto, 29 to 69; the same ten reached by their links, nothing said.

Nor is holdNotAHand: a press-and-hold — the edge's step-up page, or your own solveChallenge — that the device failed in the last hour as no hand's, and has not passed since. Events the page's own script dispatched, or a line drawn in perfectly collinear steps, are no person's: once is enough, and the session the hold was asked on is ended there and then (hold:events, hold:line among its revocations), so its receipt stops passing at the edge within seconds. A driver's press — the pointer set down on the button's exact centre with no way to it — and a mouse set down on the button from afar in one move, a computer-use agent's, a person makes now and then, so they take two. What it catches is the script that fails the hold and identifies again for a fresh receipt: the device carries what it did. Weighed 40. Measured end to end with real Chrome through the edge: a hold dispatched from the page's script not counted, its session ended, and the next identification holdNotAHand; a hand's hold and a key's held on the button, through.

Nor is pointerBetweenPixels: a mouse whose steps — the distance between two of its positions — fall between the screen's pixels, most of twenty steps or more. Windows moves a mouse's pointer, and a touchpad's, in whole device pixels, so every step a hand makes is a whole number of them: n / devicePixelRatio CSS pixels, at 100%, 125% or 175% scaling alike (a step, not a position, so wherever the page sits on the screen cancels out; a whole CSS pixel counts too, for an engine that rounds to them). A curve drawn in code lands anywhere: ghost-cursor's Bézier paths, which read as a hand's in shape and timing, step between pixels 96–100% of the time, measured at ratios from 1 to 2; a hand's steps never do — measured too with the served agent at Windows' 125% and 150% scaling, where a hand's path stays quiet and the same path drawn in fractions is named (probes/honest.mjs). Only a trusted mouse's moves are counted — not a pen's or a finger's — and not while the page is pinch-zoomed. Read under a Windows User-Agent only: macOS places the pointer at fractions of a point, and Wayland and XInput2 at fractions of a pixel, for a hand as for a script. Firefox puts the pointer on a device pixel whatever it is given, so there the reading is silent — a hand's steps and a curve's alike stay on the grid, measured at ratios from 1 to 2. Measured with ghost-cursor behind a clean Chrome identity: 7 to 17 before, 47 now. It weighs 40, like linearTween; it was measured against what Chrome makes of a mouse's whole-pixel steps, not yet against real people's mice, and a challenge is what a person passes. The session pulse reads it per window (pointer_between_pixels, +20, sticky).

biometrics

{ "result": true, "reasons": ["metronomeTyping", "constantVelocityProfile"], "foreign_profile": false }

Behavioral biometrics: the statistics of the session's motion, where the trajectory reads one path's shape. The agent accumulates Welford moments (counts, means, variances) for pointer speed/turn/jerk, keystroke dwell and flight, click-hold duration and scroll pacing — never key identity or pointer coordinates, by construction — and two judgements are made:

  • Outright cadence rules. Human typing varies flight time by 30–60%; a metronome at under 8% is machinery (metronomeTyping). Sub-3 ms dwell variance at machine speed (machineDwell), a pointer whose speed never varies (constantVelocityProfile), hundreds of segments without a single rest stop (noRestStops), no curvature (noCurvature), identical click holds (constantClickDuration). Thresholds sit an order of magnitude inside bot territory so the entire human range — assistive tools, remote desktops, tired hands — stays clear. Small samples never convict: a typing cadence is read over seven flights or more (a flight is measured only between keys under five seconds apart, so a reader paging with the space bar has none).
  • keysBackToBack: a driver's typing call. Each key held, and the next pressed the moment it is let go — under 8 ms after the release, most of eight presses or more, the keys held 20 ms or longer. Puppeteer's keyboard.type(text, { delay }) and Playwright's pressSequentially hold each key for the delay and press the next about a millisecond after releasing it, measured — however the delay is drawn, one value or a random one per key. A hand leaves a gap, or presses the next key before letting go of the last; a phone's soft keyboard and a password manager's auto-type hold nothing, and are left to machineDwell. Counted, never timed out of the page: how many presses followed another within a second, and how many of those came under 8 ms after its release (and after it: a gap of exactly nothing is a browser that coarsens its clock putting a hand's release and next press in one tick — measured with Firefox's resistFingerprinting, whose 16.7 ms ticks put a fast hand's gaps on 0, 16.7 and 50, never between, and none read tight). Measured the same in Firefox: Playwright's pressSequentially there, 18 gaps of 18 under 8 ms; a hand's pace, none. A script that waits between keys as well as on them is not read.
  • foreign_profile: the account-takeover tell. Trusted sessions train a rolling per-visitor profile (same Welford moments, server-side). Once three trusted sessions exist, a session whose cadence sits three sigmas out on two or more features is the same device in different hands. Training is guarded — allowed, low-score, untampered, unreplayed, unflagged sessions only — so an attacker cannot teach the profile their own cadence, and a young profile convicts nobody.

Corroborative at +10 for cadence findings, and keysBackToBack 40 in all (like linearTween: a challenge beside anything else, which a person passes, since it reads how software typed, and a person can use software to type); foreign_profile carries +18 because it requires the visitor's own history to fire. The same moments ride every pulse beacon, and the pulse engine runs the cadence rules per window (metronome_typing, constant_velocity, keys_back_to_back — sticky, like drift).

ato_credential

{ "result": true, "source": "observed", "label": "presented by convicted automation", "seen_count": 3 }

Credential intelligence. The page (or your backend) computes SHA-256 of the normalized username:password pair — never sent: lookups are k-anonymous, the first 40 bits of the digest query the corpus and the comparison happens on the caller — and attaches the digest to the identify payload, where the server matches it against the organization's breach corpus itself. The client cannot lie about the result: the digest either is in the corpus or is not.

source tells you which corpus spoke: feed is your uploaded breach list (+10), observed is stronger — this exact digest was previously presented by a session convicted as automation, meaning it sits on a stuffing list in active use against your deployment (+14). Neither convicts alone: a breached password makes the account a target, not the user a bot. Beside biometrics.foreign_profile it completes the classic ATO shape — known-stolen password, unfamiliar hands.

baseline

{ "result": true, "reasons": ["timeToIdentifyAbnormallyFast"] }

Behavioural timing judged against this environment's own rolling baseline (Welford mean/variance per metric, no raw values stored) rather than a global constant. Only the fast tail is suspicious: humans are slow and devices are old, so slowness means nothing — but on a site where people take two seconds to identify, a 30 ms identification is not a quick person. Needs a few hundred events of history before it can fire; until then it stays silent.

The baseline is of each timing's logarithm (timings are skewed: a typical second or two, a few minutes), and a timing is abnormally fast three spreads below the typical one and at least 150 ms short of it. It remembers the last 10,000 or so identifications, so it follows the site as it changes. Timings are the client's to report: one is taken as ten minutes at most, and, once the baseline has its history, as three spreads above the typical at most, so made-up slow timings cannot widen it until nothing is fast.

payload_integrity

{ "result": true, "path": "wasm", "sealed": true, "program": "ok" }

Which integrity scheme the payload's MAC verified under. result is true only when a MAC cryptographically verified; path is the detail:

path Meaning Score effect
wasm signed by the current per-deploy salted build −5 (trust)
legacy signed under the pre-salt scheme (strict-CSP pages, older bundles) 0
stale build id the registry has rotated out — cached page, or a guess +2
unsigned no usable MAC presented +3
invalid a MAC that failed verification, or one presented without the nonce, time and checksum it signs via tampering, heavily

program is there when the key came with a program — every key the current agent asks for — and says what the server made of its answer: ok; emulated, the answer honest and some questions answered as no browser does; forged, an answer the reported values do not give; pow, a proof of work asked for and not paid (+10); missing, no answer at all, or a key without a program signed by a build that asks for every key with one; error, the agent said it could not read it, having named a build no instance knows (+20; said of a program it could read, forged). Each of those but ok and error has its reason under bot. absent is a key without a program on a path that names no build (legacy), once every build being served runs programs — an agent from before them, or a request written to go without — and costs what unsigned does: +3. While an agent from before programs may still be served (the registry keeps the last two builds), a key without one says nothing.

The weights are deliberately small: absence can be innocent (a strict CSP without wasm-unsafe-eval, a network blip fetching the key), so this signal nudges rather than convicts — and because it is a signal like any other, the rules engine can match on it (products.payload_integrity.path == "unsigned") for sites that want harder treatment on sensitive actions. The path mix per environment is reported by GET /api/management/v1/integrity-stats.

model_score

{ "result": true, "probability": 0.87, "trainedOn": 420 }

A per-environment logistic model trained on your labels — the suspect verdicts you set through the Server API or dashboard — over the boolean outcomes of every other signal — and of how a proxy was seen, not only that one was (webrtc_leak, relay_latency: what a residential pool's household address hides, and its class cannot say). It answers the question the heuristics cannot: how often did sessions shaped like this turn out to be bots on this site? A probability at or above 0.5 adds twenty times itself to the suspect score — ten points at 0.5, twenty near certainty — once the model is right about at least 65% of the labels it was not fitted on (balanced across the classes), or more on a corpus small enough for chance to reach that (below it, it is absent here), and GET/POST /api/management/v1/model reports the weights, that accuracy and corpus size — or forces a retrain.

raw_device_attributes, browser_details

The unhashed attributes the browser reported, and the parsed browser, OS, device and version.

Mobile signals

Under products.mobile, present only for Android and iOS SDK events.

Signal Basis
root_apps su and busybox binaries, Magisk paths and manager, writable system partition, test-keys build tag, permissive SELinux, root cloakers
jailbroken Cydia, Sileo, Zebra, MobileSubstrate, libhooker, sandbox-escape write, fork() success, DYLD_INSERT_LIBRARIES, unexpected dyld images
emulator goldfish, ranchu and vbox hardware, QEMU props and pipes, Genymotion sockets, generic fingerprints, missing telephony, sparse sensors
simulator iOS simulator target environment, runtime env vars, no Secure Enclave
frida frida-server ports, injected libraries in the memory map, Frida thread names, Xposed, LSPatch, Substrate, non-zero TracerPid
cloned_app install-token binding mismatch, multi-user data paths, known dual-space containers, UID anomalies
factory_reset data-partition creation time (Android) or keychain item age (iOS), with days-since: within the week, and only of a device seen here at least an hour before it — the iOS marker is written on the SDK's first run, so a phone new to the app is no reset
location_spoofing isFromMockProvider, isSimulatedBySoftware, mock-location setting, implausible accuracy, timestamp anomalies
mitm_attack certificate-pinning failures, user-installed CAs, modified trust store, configured proxies
vpn tunnel interfaces, the OS VPN transport, Private Relay, corroborated by IP evidence and by the phone's time zone against its address's
developer_mode ADB, developer options, attached debugger, traced process
tampering_details app signature digest, repackaging, debuggable flag, untrusted installer, request signature and clock skew
attestation Play Integrity verdict, decrypted and verified under the app's own Play Console keys or decoded by Google, judged against the environment's registered packages and signing certificates: bound to this request (requestHash), fresh, PLAY_RECOGNIZED, device or strong integrity, licensing, Play Protect, unknown controlling apps, device-farm activity. On iOS, App Attest: a registered Secure-Enclave key signs each request's canonical string; the signature, rpId hash and strictly-increasing counter are verified against the stored credential. forged (bad signature, unknown key id, counter regression) and rejected (valid token, damning contents) score; unavailable and unsupported move nothing

The reasons a rule can name, by signal (products.mobile.<signal>.reasons):

Signal Reasons
cloned_app tokenBindingMismatch (the install's token bound to another instance of the app), multiUserDataPath (the app run as a phone maker's dual-apps user: Xiaomi, OnePlus and Oppo's 999, Samsung's 95, Huawei's 128), knownContainerPackage (a dual-space or cloner app), duplicatePackageInstances, unexpectedUid, sharedUserIdAnomaly; secondaryUser (a work profile, a second user, Secure Folder, a private space) is named and weighs nothing — the device's owner
location_spoofing fromMockProvider (Android says so: conclusive), mockLocationAppInstalled, developerMockLocationEnabled, implausibleAccuracy, staticCoordinates, timestampAnomaly, ipCountryMismatch (weak alone: travellers and carrier routing make it)
mitm_attack certificatePinningFailure, userInstalledCaCerts, proxyConfigured, systemTrustStoreModified, unexpectedCertificateChain
tampering_details signatureDigestMismatch (Android: the app's signing certificate, as the SDK read it, is not one the environment registered for the package in androidApps — a copy re-signed with another key; register every certificate in the app's signing lineage), repackaged, debuggable, installerUntrusted:<installer>, requestSignatureInvalid, clockSkew (the phone's clock more than five minutes off), attestationForged, attestationRejected
attestation Play Integrity: requestMismatch, packageNotRegistered, packageMismatch, staleVerdict, certificateMismatch, appVerdict:<verdict>, virtualDevice, basicIntegrityOnly, deviceIntegrityNotMet, unlicensed, playProtect:<verdict>, controllingAppDetected, capturingAppDetected, highDeviceActivity, verdictReplayed. App Attest: missingKeyId, unknownKeyId, developmentKey, packageMismatch (the key's app is not the one the request names), counterReused (forged), counterRegression (refused, far behind the key's newest), oneKeyManyDevices, and an assertion that does not verify by what failed (assertionInvalid when nothing more is known). See mobile SDKs

Measured by the Android SDK only: mockLocationAppInstalled (one of the fake-GPS apps it asks about by name), staticCoordinates (a GPS fix the same to the last bit, three times a minute or more apart; a receiver never reads the same twice). Read by the server but measured by no SDK yet, and so never present today: ipCountryMismatch (location_spoofing), duplicatePackageInstances (cloned_app), unexpectedCertificateChain (mitm_attack). A rule may name them; it will match once an SDK sends them.

Suspect score contributions

Roughly, so you can predict it:

Bad bot +45
Honeypot touched +35
Forged mobile attestation +40
Rejected mobile attestation +30
Replayed payload +35
Tampering +18, or +30 for an anti-detect browser
A value its browser makes by construction, replaced (forgedIdentity) +25 each, at most twice
Tor +25
On your own threat feed +20
Spamhaus DROP or ASN-DROP network (ip_blocklist) +20
On IPsum's lists of attack sources +6, or +12 on six or more
Condemned by other organizations (network_reputation) +8, or +15 at high
Proxy +10, or +18 at high confidence (ml_score 0.8 or more) — nothing when all it saw is a public VPN's or Tor's exit address, which the VPN or Tor row counts
Unverified autonomous AI agent +15
Impossible travel +15
A session on a network it never used (network_change) +5, or +12 into a datacentre that is not a public VPN's or Tor's exit
Virtual machine +12
VPN +6, or +12 at high confidence
Transport disagrees with user agent, the request's own headers among the reasons +30
Other network inconsistency — the connection's readings alone, a middlebox's as often as a library's, among them +15
Remote control +5, or +10 at high confidence
High-activity device +8
API calls without a gesture +14 (other API-abuse reasons +8)
Inhuman pointer trajectory +8, or +40 for a script's own motion — a tween in equal steps, every click on the exact centre, a mouse stepping between the screen's pixels, a field's text arriving whole, every page reached as a driver's goto reaches one, every click reached in one jump of the pointer, a press-and-hold no hand made (linearTween, centredClicks, pointerBetweenPixels, textInsertedWhole, holdNotAHand, navigationsTyped, jumpedClicks)
Machine cadence (biometrics) +10, or +40 for keys pressed back to back (keysBackToBack)
Foreign to the visitor's own profile +18
Breached credential presented +10, or +14 when actively stuffed
Abnormally fast vs baseline +6
How the payload proved itself (payload_integrity) −5 signed by the current build, +2 by a retired one, +3 unsigned; +3 with no program, +10 its proof of work unpaid, +20 an agent saying it could not run it
Incognito +5
Developer tools, rare device +4 each
Learned model at ≥50% probability up to +20
Verified AI agent −20

Velocity adds up to +34 more across distinct IPs, linked ids and countries. A phone's mobile signals add their own:

frida +40
tampering_details +35
cloned_app, mitm_attack +25 each
root_apps, jailbroken +22
emulator, simulator +20
location_spoofing +18
factory_reset +10
developer_mode +8

The attestation rows above are a phone's too. The result is clamped to 0–100.

Holding a reading back

Some readings are measured on tools and on honest browsers but not yet on your own people: jumpedClicks, centredClicks, textInsertedWhole, pointerBetweenPixels, linearTween, navigationsTyped, keysBackToBack, and the proxy methods webrtc_leak and relay_latency. If one turns out to cost your visitors — the counts on Smart Signals show how often each is seen, and Edge traffic → Challenges, by how they ended how often the people it challenged passed — hold it back: tick it under Smart Signals → Readings held back, or set mutedReadings on the environment through the Management API — whose reading calibration counts, for each, the challenges it led to that a hand passed and that no hand did, and says which to hold back. A reading held back is still reported in its product and its reasons, still matched by your rules and still counted on the dashboard; it adds nothing to suspectScore, to the learned model's points or to a session's pulse. That quiets it at once for everyone, where an allow rule on its reason would also let through a bot that shows it. What convicts — a driver's own frames, an automation flag — cannot be held back, nor what a forged identity adds (25 a forgery, at most twice: points, never a conviction on its own).