Rules
Signals are only useful once someone turns them into a decision. Without somewhere to express that, every integrator reimplements the same thresholds in their own backend and cannot change one without a deploy.
A rule is a condition, an action and a priority. Every identification
returns a decision alongside the signals.
{ "decision": { "action": "challenge", "matched": ["High risk checkout"] } }
Actions
allow |
Explicitly fine. Use above a broad block to carve out an exception. |
flag |
Record it, do nothing. |
challenge |
Step up: 2FA, a captcha, manual review. |
block |
Refuse. |
The engine recommends; it does not enforce. LayerSift never blocks your visitor. Only you know what the request was trying to do.
Order
Every enabled rule is evaluated, so the dashboard can show all matches, but the
first match in priority order decides. That is the firewall model: an
explicit allow placed above a broad block carves out an exception without
restructuring the reasoning. A flag never decides while a rule below it
that acts matches too: it records and the evaluation goes on, so a new rule
can be tried out as a flag at the top without switching off the rules under it.
A rule that no longer parses is skipped rather than failing the request. Paused rules are skipped entirely. An environment holds at most 500 rules.
A rule saved, paused or deleted applies within five seconds: each server keeps an environment's rules, as it keeps its request filters, threat feed and webhooks, that long between reads, and the server that made the change reads them again at once.
Expression language
Conditions are parsed, never evaluated as code — a real tokenizer, parser
and tree-walking evaluator, because a rule is untrusted input stored in a
database. Path resolution reads own properties only, so an expression can see
the event it is evaluating and nothing else. constructor and __proto__
cannot be reached. Regex patterns are length-capped and fail closed.
Operators
| Comparison | == != > >= < <= |
| Logic | and or not |
| Membership | in, not in |
| Strings | contains, startsWith, endsWith, matches |
| Address ranges | inCidr — ip inCidr ["198.51.100.0/22", "2001:db8::/32"], one range or a list. IPv4 and IPv6, a dual-stack client's ::ffff: address counted as the IPv4 it is; a range that cannot be read is refused when the rule is saved. Negate with not ip inCidr … |
| Literals | numbers, "strings", true, false, null, [lists] |
Numeric strings compare as numbers, so "70" >= 70 holds; an empty string is
not a number. null is a value that is not there, missing and set to null
alike: products.x.score == null holds for both, and a missing score is not
< 30 (nor >= 30) — a signal that did not run never matches an ordering.
==, != and in compare exactly, case and all; contains, startsWith,
endsWith and matches ignore case.
A comparison that can never be true is refused when the rule is saved, rather
than matching nothing on every request afterwards: in takes a list, == and
the string operators one value, < and the rest a number, and a list at
least one value (in [] is never true).
matches takes a regular expression in the part of JavaScript's syntax
that can be matched in time linear in the text: literals and escapes, .,
classes ([a-z], [^/], \d \w \s and their negations), \b, ^ and
$, groups ((…), (?:…)), |, and the quantifiers * + ? {n}
{n,} {n,m} (repeat counts up to 100). The text is a visitor's, so no
pattern may make it expensive: backreferences and lookarounds, which need
backtracking, are refused when the rule is saved, and a pattern is matched
against the first 4,096 characters of the text and, of a longer one, its last
4,096 — a path padded past the first still has its ending read, and $ holds
only where the text itself ends, never where a reading stops. Case is ignored. A pattern is
written in a string: a backslash it needs is kept, written once or twice
("\.env$" and "\\.env$" are the same pattern); in the string itself only a
quote or a backslash is escaped. And the patterns of all an environment's rules
share one budget per request — ten times what fifty pattern rules over a real
User-Agent, path and query spend, every step of every pattern counted. A rule
whose patterns would run past it is not read for that request, rather than slow
every request on the servers every customer shares. The text is the visitor's,
though, and a long one against an early costly rule could leave a block rule
after it unread: so a challenge or block rule left unread makes the request
a challenge at least (a block where a block matched after it; an allow that
matched before it still decides), never less than its score, with
rule-unread:<name> among its reasons. Trying a condition on recent events
reads each within the same budget and says how many it could not.
Paths
Everything under products from the event, plus the top-level fields.
products.bot.result
products.vpn.result
products.ip_info.countryCode
products.mobile.root_apps.result
velocity.distinct_ip.1h
suspectScore
confidence
linkedId
confidence is the event's confidence.score, the number itself. A path that
an event lacks is falsy rather than an error, so a rule written for a browser
signal does not explode on a mobile event. A path that no event has — a
misspelling, or a field of the other kind of rule — is refused when the rule is
saved, with the nearest field there is: it would parse, and never fire. So is
a group of fields used as a value — products.vpn, ipInfo: it is an object
on every event that has it, so products.vpn meant as "a VPN was seen" would
match them all. Name one of its fields, products.vpn.result.
Reasons are arrays
Most signals report why they fired as a list — products.tampering.reasons,
products.network.reasons, products.bot.reasons. Two operators reach into one:
reasons contains "engineNewerThanClaimed" |
An element equal to the string, exactly. engineNewerThanClaimedAndMore does not match. |
reasons matches "engineFamilyContradictsUa" |
The list is tested as its comma-joined text, so a pattern catches a family of reasons — here engineFamilyContradictsUa:claims-chromium-runs-gecko and every other variant, whatever follows the colon. ` |
Use contains when you mean one reason and matches when the reason carries a
parameter. A bare list in a condition (products.tampering.reasons) is true when
it is not empty. Numbers inside a signal are comparable like any other path:
products.tampering.engine_version.claimed < 120
and products.tampering.engine_version.at_least >= 140
Examples
products.bot.result == "bad" or suspectScore >= 70
A browser whose engine is not what its user agent says — the version, or the whole family (a Gecko build presenting itself as Chrome):
products.tampering.reasons matches "engine(FamilyContradictsUa|NewerThanClaimed|OlderThanClaimed)"
A stealth build that patched navigator and left another scope telling the
truth — the rule to put above the broad ones, since it is nearly conclusive:
products.tampering.anti_detect_browser
Narrower still: a value the claimed browser produces by construction, replaced — WebGL strings from another platform's driver, a decoy brand from an older Chrome. No honest browser does either:
products.tampering.forged_identity > 0
An identification that did not bring a browser's answer to its integrity key's
program — unsigned, signed by a build no
one knows, the program unrun, unpaid or answered as no browser does. Attack mode
holds identifications to this on its own; as a challenge rule, it holds the
environment to it always, the way the strictest vendors require a solved
challenge token — where the edge or middleware steps receipts up
(ANTIBOT_ENFORCE_STEP_UP=hold-all, stepUp: "hold-all"), so that the receipt
such an identification earns meets the hold; elsewhere it says challenge, and
acting on it is the site's. A field that is not there is not "ok", so a
payload with no program at all matches too:
products.payload_integrity.program != "ok"
Client Hints copied from a different Chrome or a different operating system.
Note it names the contradiction, not missingClientHints: a privacy extension
suppresses the hints for an honest browser, and one missing observation is not a
reason to challenge anyone:
products.network.reasons matches "clientHintsContradictUa"
A browser the edge timed further away than the connection that reached it — a proxy that terminates TCP between them, a residential pool's exit. Read from a tab's second page on, and only behind a proxy that reports the connection's round trip (the browser's distance):
products.proxy.methods.relay_latency
A driver's hands: a field's text arriving whole with no key pressed for it
(fill()), every click reached in one jump of the pointer (a driver's
mouse.move, a computer-use agent's xdotool), and — where the edge places the
agent — every page of ten minutes reached as goto() reaches one. Each a
challenge, as their templates are:
products.behaviour.reasons contains "textInsertedWhole"
or products.behaviour.reasons contains "jumpedClicks"
or products.behaviour.reasons contains "navigationsTyped"
products.ip_info.countryCode not in ["GB", "IE"] and velocity.distinct_ip.1h > 5
products.mobile.root_apps.result or products.mobile.frida.result
products.ai_agent.result and not products.ai_agent.verified
and products.ai_agent.kind == "agent"
products.remote_control.result and products.remote_control.confidence == "high"
and suspectScore >= 30
Rules for requests without the agent
A rule applies to identifications (the default — the agent ran) or to requests without the agent: what the assess call judges, at the edge or from your server. The two are written against different fields, and every existing rule stays an identification rule. An assessment rule sees:
| Field | |
|---|---|
path, method, host, query, url |
The request |
userAgent, headers.<name> |
Its headers, names lowercase (cookies and authorization are never there) |
ip, ipInfo.countryCode, ipInfo.asn, ipInfo.asnOrganization, ipInfo.datacenter, ipInfo.vpn, ipInfo.tor |
Where it came from |
ipInfo.crowd |
The address is many people's: a privacy service's exit (a VPN's, a relay's, Tor's), or one twenty devices ran the agent from in the last hour — a carrier's, an office's. The scoring weighs what is counted of such an address as everyone's (shared addresses); a rule on the address's counts — velocity.accountsFromAddress, card.cardsFromAddress — can do the same with not ipInfo.crowd, beside the device's own |
score, action, reasons |
What the documented scoring reached, before rules |
bot.result, bot.type, crawler.status, crawler.bot |
What it is |
crawler.category, crawler.name, crawler.operator |
What a crawler the User-Agent names is for: search, social, seo, monitoring, aiTraining, aiSearch, aiAssistant — crawlers by purpose |
reputation |
none, low, medium, high — network reputation |
trap.followed |
The address requested a trap link in the last six hours |
rate.hourly |
Requests assessed from the address in the last hour without a valid receipt |
rate.devicesFromAddress |
Devices that ran the agent from the address in the last hour, paid its proof of work and were not convicted, in this environment — a household's few, a carrier's NAT's or an office's many. From twenty the address is weighed as a crowd's, as a VPN's exit is (assessing a request) |
rate.pathPerMinute |
Requests assessed from the address to this one path (case-blind, a trailing / aside) in the last minute without a valid receipt, counted across instances — a scraper walking a search or a product listing: rate.pathPerMinute > 60 |
rate.pagesWithoutAgent |
Where the agent is on every page — placed by the edge (ANTIBOT_AGENT_INJECT=on on Cloudflare, Azure or Fastly), or said by agentOnPages: pages this client — its address, User-Agent and languages, read as a set of tags — asked for in the last ten minutes without the receipt the agent writes |
network |
What per-address counts are keyed on: the address for IPv4, its /64 (2001:db8:1:2::/64) for IPv6 |
now |
The time, in milliseconds since 1970 — for a rule that should stop matching (now < 1793000000000) |
velocity.accountsFromAddress, velocity.accountsFromNetwork (across an IPv6 source's /48; 0 for IPv4), velocity.addressesForAccount, velocity.failuresFromAddress, velocity.successesFromAddress, velocity.failuresForAccount, velocity.variantsOfAccount, velocity.accountsFromDevice, velocity.devicesForAccount, velocity.failuresFromDevice, velocity.successesFromDevice, velocity.newDevice, velocity.newCountry |
For requests that named an account; the device fields need a valid receipt, the new ones reported sign-in outcomes |
email.present, email.domain, email.disposable, email.freeProvider, email.subaddressed, email.generated, email.relay |
When that account is an email address — what the address says |
card.present, card.cardsFromAddress, card.cardsFromNetwork, card.cardsFromDevice, card.addressesForCard, card.declinesFromAddress, card.approvalsFromAddress, card.declinesFromDevice, card.approvalsFromDevice, card.declinesForCard |
For payments that named a card — protecting checkout |
user.present, user.addresses, user.requests, user.countries |
For requests that named the signed-in user: its distinct addresses and requests in the last hour, and the countries it was placed in within the last half hour (by addresses that say where a person is — never a VPN's, a relay's, a datacenter's or a Tor exit's): user.countries >= 2 before a payment or a change of email is where to ask for the password again (the template Flag a session in two countries at once) |
userSession.present, userSession.known, userSession.otherDevice, userSession.otherClient, userSession.ageMs |
For requests that sent their signed-in session (userSession, a digest of your session cookie): whether it was bound before, whether it is used from another device than it was bound to (both with a receipt) or another browser, system or kind of device, and how long since it was bound — its cookie stolen and replayed elsewhere (the template Ask again when a session moves to another device) |
content.present, content.words, content.links, content.linkHosts, content.sendersOfContent, content.postsFromAddress, content.postsFromDevice, content.postsFromNetwork, content.reportedSpam, content.approved |
For submissions that sent content — protecting forms |
agent.signed, agent.verified, agent.signer, agent.host, agent.operator, agent.name, agent.intent |
The AI agent that signed the request (Web Bot Auth): agent.verified and agent.operator == "OpenAI" is ChatGPT agent, proved by its key. verified is proved by keys someone vouches for — a known operator's directory (OpenAI's, Google's, DuckDuckGo's) or a payment network's registry — never only that the signature checks out: anyone can publish keys and sign. signer is the directory whose keys made a valid signature, vouched for or not: agent.signer == "agents.partner.example" trusts a partner of yours by name. A shopping agent a card network registered (Trusted Agent Protocol) has the network as operator and intent "browse" or "pay": agent.verified and agent.intent == "pay" at checkout is one paying for a person (the template Let shopping agents a card network vouches for pay, while what it does there scores under 50); intent is empty unless verified |
attack.on |
The environment is under attack mode |
form.unread |
account, code or content when an edge could not read a configured form's field |
code.present, code.codesFromAddress, code.codesFromNetwork, code.codesFromDevice, code.addressesForCode, code.failuresFromAddress, code.successesFromAddress, code.failuresFromDevice, code.failuresForCode |
For redemptions that named a code — protecting gift cards |
flow.present, flow.name, flow.step, flow.sincePreviousMs, flow.skipped, flow.rushed, flow.rushedSteps |
For a step of one of your flows (flow): its name as sent (lower-cased), the step, the milliseconds since the step before (an hour when there was none: test flow.skipped beside a rule that reads a long gap as a person's), and whether it was skipped or taken too soon — protecting checkout |
graphql.present, graphql.documents, graphql.persisted, graphql.persistedRepeated, graphql.unread, graphql.operations, graphql.mutations, graphql.names, graphql.fields, graphql.mutationFields, graphql.repeated, graphql.repeatedField, graphql.mutationRepeated, graphql.mutationSelections, graphql.depth, graphql.introspection, graphql.malformed |
For requests that sent their GraphQL (graphql): the documents the request carried, how many were persisted queries whose text was not known (and the most times one came) and how many could not be read through, the operations that would run and how many were mutations, their names and the fields at their top — all of them, and mutations' alone (lists: graphql.mutationFields contains "login") — the most times one field repeats in the request (by aliases, by the batch) and of mutations alone, the mutation fields selected in all, how deep it selects as a server runs it (fields' levels: a fragment counted where it is spread, an inline fragment no level, an input object in an argument — a where: { … } — no level), whether it asks for the schema, and whether a document was one a server refuses (templates Stop sign-ins batched into one GraphQL request, Refuse GraphQL introspection from outside) |
hold.present, hold.unitsFromAddress, hold.purchasedFromAddress, hold.itemsFromAddress, hold.unitsFromDevice, hold.purchasedFromDevice, hold.unitsFromNetwork, hold.purchasedFromNetwork, hold.itemHoldsFromAddress, hold.itemLapsesFromAddress, hold.itemPurchasedFromAddress, hold.itemHoldsFromDevice, hold.itemLapsesFromDevice, hold.itemPurchasedFromDevice, hold.unitsForItem, hold.purchasedForItem, hold.holdersForItem |
For holds that named a hold — protecting stock from being held |
shipping.present, shipping.country, shipping.countryMatchesIp, shipping.accountsForDestination, shipping.cardsForDestination, shipping.devicesForDestination, shipping.addressesForDestination, shipping.variantsForDestination |
For orders that named a shipping address — protecting limited releases. countryMatchesIp is true when either country is unknown |
phone.present, phone.callingCode, phone.nonGeographic, phone.phonesFromAddress, phone.phonesFromNetwork, phone.phonesFromDevice, phone.addressesForPhone, phone.sendsToPhone, phone.numbersInRange, phone.verifiedInRange |
For SMS sends that named a phone — protecting text messages |
session.present, session.valid, session.challenged, session.reason |
The receipt it carried, if any; challenged when it was earned by passing a challenge rather than by identifying |
session.visitorId |
The device a valid receipt names ("" without one): what letting one device through matches, where its address is shared |
session.silent, session.pulses |
A browser's receipt more than five minutes old whose page never pulsed (session:silent, reported and never weighed): the agent stopped running where it was minted, or a script carries it — and a fast clicker whose pages each close before their first beat, or a blocked beacon, looks the same. pulses is how many beats its page sent, -1 where they were not asked (a younger receipt, a phone's, none) |
path startsWith "/checkout" and bot.result == "bad"
ip inCidr ["198.51.100.0/24"] and path startsWith "/partner-api"
path == "/login" and not ipInfo.crowd and not session.challenged and velocity.failuresFromAddress >= 10 and velocity.successesFromAddress <= 1
path contains "signup" and not session.challenged and (email.disposable or velocity.variantsOfAccount >= 3)
path == "/trial" and email.freeProvider
card.present and (card.cardsFromDevice >= 5 or (not ipInfo.crowd and not session.challenged and card.cardsFromAddress >= 5))
phone.present and phone.callingCode not in ["1", "44"]
crawler.category == "aiTraining"
ipInfo.countryCode in ["KP", "IR"] and path startsWith "/account"
The first matching rule decides, as for identifications: block and
challenge act, allow lets the request through whatever the score was — an
allow first is how a partner's script or an uptime monitor is let in — and
flag only reports, and lets the next match decide. Every match is added to the answer's reasons as
rule:<name> and counted on the rule. Your filters
still run first. A rule's verdict is your policy, not evidence: it never feeds
network reputation. Assessed requests are
counted, not kept, so the dry run only checks that the condition parses; Edge
traffic shows what a rule matched once it is live.
Writing one
Rules → New rule. Start from a template if one fits. Before saving, use Dry run against recent events — it evaluates your condition against real traffic and reports how many of the last events would have matched. A rule that matches everything or nothing is usually a mistake, and this is where you find that out rather than in production.
An invalid condition is rejected at write time, not silently never fired — one that does not parse, and one that names a field no event of its kind has.
Acting on the decision
const event = await antibot.getEvent(requestId)
switch (event.decision?.action) {
case "block": return deny()
case "challenge": return stepUp()
case "flag": log(event); return allow()
default: return allow()
}
Read the decision from your server, not from the browser result. A decision the client hands you is a decision the client can edit.