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 astampering; a missing Battery API counts only for a browser that claims Chromium and is not Brave. This signal names the tool and adds nothing tosuspectScore. - 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_integrityreads 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 tosuspectScore) 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'skeyboard.type(text, { delay })and Playwright'spressSequentiallyhold 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 tomachineDwell. 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'spressSequentiallythere, 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).