Server SDKs
Seven languages, one surface. Each is driven against a live server in CI rather than only compiled, and each opens a sealed payload written by a different language — the envelope is a binary format shared across runtimes, so sealing and unsealing in one would pass while being wrong.
| Language | Package | Dependencies |
|---|---|---|
| Node | @antibot/node-sdk (and @antibot/node-sdk/middleware) |
none |
| Python | antibot |
none (antibot[sealed] for sealed results) |
| Go | github.com/antibot/antibot-go |
none |
| Java | dev.antibot |
none |
| PHP | antibot/antibot-php |
none beyond bundled extensions |
| .NET | Antibot |
none |
| Ruby | antibot (gem; Antibot::Middleware for Rack) |
none |
Zero runtime dependencies is deliberate: this sits in the request path of a checkout, where a dependency tree is a liability.
Every client has
getEvent(id) |
One identification, with every signal |
searchEvents(filters) |
Search |
iterateEvents(filters) |
Auto-paginating walk |
updateEvent(id, patch) |
Attach linked id, tags, suspect label |
deleteEvent(id) |
|
getVisitor(id) |
Visit history |
deleteVisitor(id) |
Right-to-erasure |
relatedVisitors(id) |
Devices sharing an IP or linked id |
assess(request) |
A verdict on a request that never ran the agent — see assessing a request |
reportOutcome(ip, account, outcome, sessionToken?) |
How a sign-in turned out, and the receipt its attempt carried ("" for none): the device it is counted to — see protecting sign-in |
reportPaymentOutcome(ip, card, outcome, sessionToken?) |
How a payment turned out, and the receipt its request carried — see protecting checkout |
reportPhoneOutcome(ip, phone, outcome) |
Whether a texted code was entered — see protecting text messages |
reportContentOutcome(ip, content, outcome) |
A moderator's verdict on a posted message — see protecting forms |
reportCodeOutcome(ip, code, outcome, sessionToken?) |
Whether a gift card, voucher or coupon was accepted, and the receipt its attempt carried — see protecting gift cards |
reportHoldOutcome(ip, item, outcome, quantity?) |
Whether held stock was bought or lapsed unpaid — see protecting stock from being held |
reportTrap(ip, headers) |
A request for a trap link your own server answered (the edge reports its own) |
Beside the client
These call no API — a key or a secret checks each where it is — so they are
functions of the package, not methods of a client: imported beside it in
Node (import { verifySessionToken } from "@antibot/node-sdk") and Python
(from antibot import verify_session_token), the package's own in Go
(antibot.VerifySessionToken) and Ruby (Antibot.verify_session_token), and
static methods in Java, PHP and .NET, on SessionTokens, Webhooks and
Sealed (.NET's receipt check is SessionTokens.Verify).
unsealEventResponse(sealed, keys) |
Open a sealed result |
verifyWebhookSignature(...) |
Webhooks |
verifySessionToken(token, keys, { userAgent? }) |
Check a receipt offline: signature, expiry, the client it was minted for (the third argument is options; the user agent is named in it) |
verifyAdmission(token, keys, { room?, clientIp? }) |
Check a waiting-room admission offline: signed with the admission key the session keys derive, unexpired, naming the room, and — given the client's address — from the network that queued where the room binds it (ipMismatch) — then bind it to the receipt beside it (waiting rooms). A receipt is never an admission, nor an admission a receipt |
verifyRequestProof(claims, proof, method, path) |
A key-bound mobile receipt's per-request proof (mobile) |
A request id the browser sends is one identification's: read with
getEvent, hold it to moments ago, and to once
— its timestamp and a claim on its id — or one kept from a real browser
and sent again re-reads as that browser on every request.
The arguments are named here, not placed. Node takes them as one object
(reportOutcome({ ip, account, outcome, sessionToken }),
verifyRequestProof(claims, { proofHeader, method, pathAndQuery })); Python
and Ruby as keywords (session_token=, session_token:); Go as options after
the context (ReportOutcome(ctx, ip, account, outcome, antibot.WithSessionToken(token))).
For a sign-in's and a code's outcome, Java, PHP, .NET and Python take an
inferred flag before the receipt — reportOutcome(ip, account, outcome, false, sessionToken) in Java, a named sessionToken: in PHP, inferred: false, sessionToken: token in .NET; a payment's takes the receipt fourth.
Names follow each language's conventions (verify_session_token in Python,
VerifySessionToken in Go, SessionTokens.verifySessionToken in Java and PHP,
SessionTokens.Verify in .NET, Antibot.verify_session_token in Ruby; and for
admissions verify_admission in Python and Ruby, VerifyAdmission in Go,
SessionTokens.verifyAdmission in Java and PHP, SessionTokens.VerifyAdmission
in .NET — the client's address as client_ip: in Python and Ruby, a fourth
argument in Java, PHP and .NET, and VerifyAdmissionFrom in Go).
Repeating a filter value ORs it; different filters AND together. Errors are
typed and carry the API's own code — branch on that, not on the message.
Node
npm install @antibot/node-sdk
import { AntibotClient } from "@antibot/node-sdk"
const antibot = new AntibotClient({
apiKey: process.env.ANTIBOT_SECRET_KEY,
endpoint: "https://api.antibot.dev",
})
const event = await antibot.getEvent(requestId)
if (event.products?.bot.result === "bad") deny()
for await (const e of antibot.iterateEvents({ linkedId: "user_42" })) {
console.log(e.requestId)
}
Middleware: the edge enforcer, in your Node server
With no edge in front of the site, antibotMiddleware makes the edge
enforcer's decisions in Express (or plain node:http): mount it after your body
parsers and it assesses what it protects and acts on the answer.
A browser's CORS preflight — an OPTIONS with no body and no credentials (no
Cookie, no Authorization), naming its Origin and the
Access-Control-Request-Method it asks leave for — goes on to your
application unjudged, in this guard and every language's below, as at the
edge: it can carry no receipt, and the request it asks for is assessed when it
comes. No assessment is made, or billed, for a preflight
(sdk/preflight-cases.json is what each reads as one).
import express from "express"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotMiddleware } from "@antibot/node-sdk/middleware"
const app = express()
app.use(express.urlencoded({ extended: false }), express.json())
app.use(antibotMiddleware({
// A key allowed to assess and nothing else.
client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
// The header your load balancer or CDN sets — never one a client can write.
clientIp: (req) => req.headers["cf-connecting-ip"],
protect: ["/shop", "/api"],
logins: [{ path: "/login", field: "email" }],
codes: [{ path: "/gift-card/redeem", field: "card" }],
contents: [{ path: "/reviews", field: "review.body" }],
challenge: { publicKey: process.env.ANTIBOT_PUBLIC_KEY, agentUrl: "/_ab/agent", endpoint: "https://shop.example" },
}))
app.post("/login", async (req, res) => {
if (req.antibot?.verdict?.action === "challenge") return askForSecondFactor(req, res)
const ok = await checkPassword(req.body.email, req.body.password)
await req.antibot?.outcome(ok ? "success" : "failure")
// …
})
The clientIp above is right only when the origin accepts connections from
your CDN alone: a client that reaches the origin directly can write
cf-connecting-ip itself. Otherwise read the last hop your own proxy appended
— handed a list (req.headers["x-forwarded-for"] whole), the Express and
Fastify middleware take its last entry.
A multipart sign-in must be parsed before the guard runs, or the guard cannot
read it and reports it unread. Mount multer ahead of the middleware for that
path: app.use("/login", multer().none()), then app.use(antibotMiddleware(…)).
A middleware inside an app or router mounted under a prefix
(app.use("/shop", shop)) reads the shop's own paths as well as the whole one:
a configured /login is the request for /shop/login, and so is /shop/login
written whole. The assessed URL keeps the prefix.
If your pages load the agent, say so — agentOnPages: true (agent_on_pages=True
in Python): a client that keeps asking for pages without the receipt the agent
writes is then challenged at its fifth, which is what stops an HTTP client
imitating Chrome on the wire. Where receipts are verified in the middleware
(every one of them, with session keys), it also counts which of a receipt's
requests were page navigations: a receipt let through to eight with its agent
never heard from — a browser's cookie carried into an HTTP library — is revoked
by the API, and the answer to the middleware's report says so, so the next
request is no pass.
In TypeScript, importing the middleware types req.antibot on Node's request,
and so on Express's — optional, because only the requests it protects carry
it. The package is an ES module: from CommonJS, require("@antibot/node-sdk")
works on Node 20.19+ and 22.12+, and its types resolve under every
moduleResolution (node, node16/nodenext, bundler).
Request by request, as the edge does:
A valid receipt (
_abid, verified here againstsessionKeys— the keys you pass, not the ones the service still holds: a key deleted in the dashboard after a leak verifies here until you take it out of them, as at the edge) passes without a call; a condemned one (blocked, scored atmaxScore— 70 — or above, or minted for a browser the agent convicted of automation: a signal inblockSignals,["bot"]by default —block_signalsin Python) is a 403. A configured form is assessed whatever it carries. A receipt bound to a phone's key (cnf, from the mobile SDKs) passes only with a fresh proof by that key in the request'santibot-popheader, checked here; without one it is assessed like a request without a receipt, with the token and the proof sent for the API to judge, as the edge does. (Python checks the proof withcryptography; without it installed, every bound receipt goes to the API.)boundPaths: API calls only your pages can make. Path prefixes only your own pages call, where a receipt counts only when it is bound to a key the caller holds — the browser agent'sbindReceipt(API calls only your pages can make), or a mobile SDK's binding — and comes with a fresh signature by that key inantibot-pop, as the edge'sANTIBOT_BOUND_PATHS. There the token header alone is read, never the cookie a solver's browser can hand on; a call without such a receipt is a 403 whatever the assessment would say (it would read an HTTP client copying a browser as fine), andantibot-enforcementsays why —absent,expired,binding:unbound,binding:pop:missing,binding:pop:invalid,binding:pop:stale,revoked— for the page's agent to renew its receipt and send the call again. In monitor mode the call goes on, with that as the would-act reason. A bound path is protected whateverprotectsays, and the option needssessionKeys(the guard refuses to start without them). Python and Ruby name itbound_paths(Python also needscryptography), Go and .NETBoundPaths, JavaboundPaths(...); Laravel readsANTIBOT_BOUND_PATHS, Symfonybound_paths, Spring Bootantibot.bound-paths.flows: a checkout run faster than a person runs it. The steps of your own flows by name, each the request that takes it, in order —{ checkout: ["POST /cart/checkout", "/checkout/delivery", "/checkout/payment@3000"] },[METHOD ]path[@minMs], POST unless said (a GET only as a page navigation), the path exact — as the edge'sANTIBOT_FLOWS. A request that takes a step is assessed with it, behind a passing receipt too, and read against the step before (steps taken faster than a person). A challenge there, behind a receipt, is the press-and-hold (withchallengeset): over the page for a form a browser posts,403withantibot-step-up: holdfor an API call. Covered whether or notprotectnames the path. A step that is also a configured form (a sign-in, a card's, a code's) is read as the form: its challenge goes on to the route with the verdict, for the route to act on, as every form's does — as at the edge. Laravel readsANTIBOT_FLOWSin the edge's own words, Symfonyflows, Spring Bootantibot.flows.<name>.graphql: one request that is many. Your GraphQL endpoints, by path (["/graphql"]) — one path for a sign-in, a checkout and a search alike, where one request can be a hundred sign-ins as aliases of a mutation, or a batch. A request there is assessed with its documents (graphql): a JSON body's (one, or a batch's), a form's fields, an upload'soperations, anapplication/graphqlbody's, and the URL'squerybeside any of them — each with its string values emptied, comments and spacing out, so a password written inline never leaves your server, and each distinct text sent once with the times it came. A body there the guard cannot read may hold anything, and is sent as a document cut short, which the API counts as unread: mount a JSON body parser before the middleware (Express), and graphql-upload's too. A mutation, a batch, or a document cut short is assessed behind a passing receipt too; a page's queries behind one go through as any request does. A persisted query's hash is read as its text once the process has seen the two together, or whengraphqlPersisted(your trusted documents, id to text) names it, and is otherwise counted. Covered whether or notprotectnames the path.graphqlOf(body)makes the field for a call of your own; the edge readsANTIBOT_GRAPHQL. Python and Ruby name themgraphqlandgraphql_persisted, Go and .NETGraphQLandGraphQLPersisted, Javagraphql(...)andgraphqlPersisted(...), PHPgraphqlandgraphqlPersisted(afterflows); Laravel readsANTIBOT_GRAPHQLandANTIBOT_GRAPHQL_PERSISTED, Symfonygraphqlandgraphql_persisted, Spring Bootantibot.graphqlandantibot.graphql-persisted.sessionCookie: a signed-in session, bound to its device. The name of your own session cookie ("connect.sid"). Every assessment carries it asuserSession— its SHA-256 (sessionDigest(value)), never the cookie — and the API binds it to the device and the browser that first used it: the same session from another device, or another browser or system, is a cookie lifted from its owner and replayed elsewhere (userSession:otherDevice,userSession:otherClient). On a covered path behind a passing receipt, each new pair of session and device is assessed once and a challenge there is the press-and-hold; a pair let through is not asked about again for ten minutes, unless the verdict said the session was elsewhere — then each of its requests carries that on, for you to ask for the password again. The edge readsANTIBOT_SESSION_COOKIE; Python and Ruby name itsession_cookie(Django'sANTIBOTsettings too), Go and .NETSessionCookie, JavasessionCookie(...), PHPsessionCookie(aftergraphqlPersisted, or a request's ownsessionid); Symfonysession_cookie, Spring Bootantibot.session-cookie, and LaravelANTIBOT_SESSIONS=true, which reads the session's id itself. Each SDK's digest issessionDigestin its own spelling.Name a cookie whose value stays for the session's life: a session id —
PHPSESSID,JSESSIONID, Express'sconnect.sid, Django'ssessionid(database or cache sessions), ASP.NET Core's.AspNetCore.Session. A cookie written anew on every response — Laravel's (encrypted afresh each time: useANTIBOT_SESSIONS), Django's signed-cookie sessions, a JWT refreshed on each request — is a new session every time: nothing is ever bound, and behind a receipt every request is assessed. One rewritten now and then (Rails' cookie store when the session changes, ASP.NET's authentication cookie when it slides) binds again from each new value.Your
blockSignalsgo with the assessment (when you set them in Node; always in Python), so a form the API judges with the receipt on it is held to the same signals — an empty list there is none there too.Everything else is assessed — the address
clientIpgives, the headers in the order they arrived (neverCookieorAuthorization), and for a configured form its one field from the parsed body: a sign-in's account, a redemption's code, a message. Never the password beside them.A form whose field cannot be read is assessed as one, never passed as if the field were absent: a body past the cap (32 KB, 96 KB for a message; declared or streamed), a type that is not urlencoded, JSON or multipart, a multipart body no parser has run on (the route parses it after the guard), JSON that does not parse, a JSON number that is not an exact integer (past 2^53 − 1, or fractional: the parser rounds it, so the code counted would not be the one redeemed), an object or a list where the value goes, a file — and the field sent twice, since PHP reads the last and most else the first, so a decoy would be counted while another account signed in. So is the field sent another way some framework reads as the same: a name PHP strips (
email), qs and Rack read through ([email]), ASP.NET matches ignoring case (EMAIL), one behind a;that python-multipart and Rack 2 split on; a body the application inflates (Content-Encoding), or in another charset; a multipart body with two boundaries, a part with two names, an encoded part; a JSON body that is no object (a list, a string, a number,null), or an object or a list where the field's value should be. Every guard and the edge read the same 61 requests alike (sdk/field-cases.json). JSON keys are matched ignoring case, as Go's encoding/json and ASP.NET match them (andſass, as Go folds it): one spelling is the field, two are a decoy. A nested field is one field however it is named:user[email]anduser.emailboth nameuser=… email=…in a form or the query string, sent either way (Rails and qs read the first, Spring the second), and{"user": {"email": …}}in JSON — so the Devise sign-in'suser[email]is read from a JSON body too; both spellings at once are a decoy, and so areuser_emailand an uncloseduser[email(PHP's readings) and Spring's!emaildefault beside an absentemail. Content-Transfer-Encoding7bit,8bitandbinaryon a part are read (Apache HttpClient writes them on every part); any other is an encoded part. It is sent asunreadField(the reasonform:unread:<kind>), never the value. A field that is simply absent — a two-step sign-in's second step — says nothing.Paths are matched as the application reads them, as at the edge:
/LOGIN,/%6Cogin,//login/,/x/../login,/login;jsessionid=…and an absolute-form target (POST http://shop.example/login) are all the/loginform, and all under a protected/login. A path is read three ways — resolved, each segment decoded once, and as written — and a prefix covers it when it covers any: Express, ASP.NET and Go's ServeMux keep..or%2F..inside a segment, so/admin/report/42%2F..%2F..%2Faboutreaches an admin handler.A form is any request that is not a read — a PUT's or a PATCH's body is read by
r.FormValueand model binding as readily as a POST's — and any request that carries a body (aContent-Lengthabove zero, or aTransfer-Encoding), whatever its method: anapp.all()route reads an OPTIONS's body, and Laravel'sinput()a GET's JSON. Without a body, a GET or HEAD is a form only when its query names the field; an OPTIONS is no form (a CORS preflight).A flat key is the field too. A nested field's name may arrive as one flat key that a parser left flat:
{"user.email": …}in JSON, oruser.email/user[email]in a urlencoded body Express parses withextended: false(and Express 5's default). It is read there when the nested form is absent; beside it, or beside a second flat spelling, it is a decoy and unread.The assessed URL's host is the request's
Hostonly when it is one: a name or address with a port of 1–65535.shop.example:99999once made the risk call a 400, and every guard lets a 400 through.No address is no pass. When
clientIpfinds no address, the connection's own is used; a request with none at all (a runtime that knows none) is still assessed, as an unknown address, and the first one is reported once to your error hook (onError), saying how to setclientIp.The call never outgrows the API. The receipt is cut to 4,096 characters, its proof to 256, the URL to 2,048, a header's value to 2,048, at most 64 headers, and the headers to 32 KiB as the call encodes them — a client padding a cookie with bytes past 0x7F, which JSON writes as six-byte escapes, once pushed the call past the API's 256 KiB and through the guard's fail-open. Make the origin accept only your CDN's address ranges as well: a client that goes round the CDN without its header is otherwise assessed by its own address, which is the right answer, but nothing in front of it saw it.
WebSockets are assessed at the edge. The Express and ASGI middlewares see only HTTP requests: an upgrade goes to the server's
upgradeevent (or an ASGIwebsocketscope) without passing through them. Put a socket endpoint behind the edge, or check the receipt on the upgrade yourself.The query string is read beside the body, because Spring's
getParameter, Rails'paramsand ASP.NET's binding read it there:POST /login?email=victimwith no email in the body is that sign-in. The field sent there twice, or there and in the body with another value, is assessed as unread.A block is a 403 with a reference the visitor can quote. A challenge to a page a browser is navigating to is the interstitial that identifies it and reloads (with
challengeset); to anything else a 403ChallengeRequired; to a configured form, nothing — it goes on to your handler with the verdict onreq.antibot.verdict, so a sign-in can ask for a second factor instead of showing an error where the login page was. The block page and the interstitial speak the visitor's language fromAccept-Language, in the same words as the edge's pages — forty languages (every official EU language but Irish and Maltese), Arabic, Hebrew and Persian laid out right to left, English otherwise, withContent-Languageset to match; every SDK carries the same table (sdk/page-text.json) and chooses from it the same way. An API call's JSON answer stays in English.Outcomes.
req.antibot.outcome("success" | "failure")reports a sign-in or a redemption in your own words — and only a success said this way is remembered as a device the account signs in from. Without it the outcome is read from your response by the same rules as at the edge, and sent asinferred: a sign-in's 401 and 422 a failure, a redirect back to the form a failure, a JSON 2xx a success, a 2xx page, 403 and 429 nothing; a redemption's 400, 404, 409, 410 and 422 a refusal, a JSON 2xx an acceptance, and anything else nothing — a redirect too, as after post-redirect-get a refusal and an acceptance both redirect, so a redemption form that redirects says its outcome itself. Nothing at all after a challenge. An attempt's outcome is counted once, in every SDK: the first said or read stands, so saying it twice, or after it was read from the answer, counts nothing more.missing: "challenge"runs the interstitial for a page navigation without a passing receipt even when the assessment allowed it — the edge's challenge posture, and what stops a headless browser with a stealth layer, whose requests look exactly like a person's. Verified crawlers, vouched agents and API calls are left to the assessment.stepUp: "hold"holds the challenge band to a press-and-hold — the edge's step-up, the same page in the same words. A page navigation carrying a receipt minted on achallenge, or one whose visitor's session the pulse hardened to a challenge since (the list comes with the revocations), is answered with the hold until a hold earns a receipt of its own (ch). API calls and form posts go on — with"hold-all"an API call is refused instead,403 StepUpRequiredwithantibot-step-up: hold, for the page'sagent.fetchto show the hold and send it again, and a form a browser posts is shown the hold. Under attack mode — the revocations say when — a receipt due a step-up is held as under"hold-all", whatever this is set to. It needschallengeset, and does nothing without it. In monitor mode the request goes on withstepUp:receiptorstepUp:sessionas the would-act reason. Python and Ruby name itstep_up, GoStepUp, .NETStepUp = AntibotStepUp.Hold.The API out of reach (no answer within the guard's
timeoutMs, 2,000 by default, or the client's own if that is shorter): the request goes on, andonErroris told. While the API is slow every assessed request waits this long — the edge waits 250 ms — so keep it to what a sign-in can wait. Python names ittimeout, in seconds (Django'sANTIBOT["timeout"]is the guard's wait as well as its client's), GoTimeout(zero isDefaultAssessTimeout), .NETTimeout, Rubytimeout:(seconds), PHPtimeout(whole seconds, at least 1; Laravel's and Symfony'stimeoutis the guard's wait as well as its client's), Javatimeout(Duration)(Spring Boot'santibot.timeoutis both).What receipts let through is reported in batches, behind the answer, as the edge does it.
- A batch goes ten seconds after its first pair (a receipt and an address), or at 500 pairs, and carries at most 1,000. What did not fit goes on the next request; up to 5,000 pairs are held.
- A receipt used from five networks, or a device at a machine's pace, is revoked.
- The environment's revocations are fetched every fifteen seconds, and a revoked receipt is assessed instead of passing.
- In Python, both run on a background thread.
- In Go, both run on goroutines, on
context.WithoutCancel(r.Context()). - In .NET, both run on the thread pool, outside the request's context.
- In Java, both run on the guard's daemon threads.
- In Ruby, both run on threads of their own;
middleware.settlewaits for them. - The Fetch-API guard returns the work as
decision.background, forevent.waitUntil.
Fastify: the same enforcer, as a plugin
antibotFastify (from @antibot/node-sdk/fastify) is the middleware for
Fastify. Register it, and it assesses and acts on every request it protects,
as above. It has no dependency on fastify: it is marked the way
fastify-plugin marks a plugin, so its hooks apply to the whole application,
including routes registered in child plugins. Register it on the root instance,
not inside a child context: registered in a child, its hooks cover only that
child.
import Fastify from "fastify"
import formbody from "@fastify/formbody"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotFastify } from "@antibot/node-sdk/fastify"
const app = Fastify()
// Fastify parses JSON out of the box; a urlencoded sign-in needs this.
await app.register(formbody)
await app.register(antibotFastify, {
client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
clientIp: (req) => req.headers["cf-connecting-ip"],
protect: ["/shop", "/api"],
logins: [{ path: "/login", field: "email" }],
})
app.post("/login", async (request, reply) => {
if (request.antibot?.verdict?.action === "challenge") return askForSecondFactor(request, reply)
const ok = await checkPassword(request.body.email, request.body.password)
await request.antibot?.outcome(ok ? "success" : "failure")
// …
})
As with the middleware, clientIp above is right only when the origin accepts
connections from your CDN alone: a client that reaches the origin directly can
write cf-connecting-ip itself. Otherwise read the last hop your own proxy
appended.
- Where the verdict is:
request.antibot, set inpreHandler, after the body is parsed; the same object is onrequest.raw.antibot. A block is answered there and the route never runs; a challenge to a configured form goes on to the route with the verdict, as in the middleware. clientIpreceives Node's request (req.headers,req.socket), not Fastify's wrapper, so it reads the same headers as the Express version.- Outcomes are read in
onResponse, from the reply the route sent, or said by the route withrequest.antibot.outcome(...), as above. - A body no parser took. Fastify parses JSON and
text/plainby default, and answers a urlencoded body with 415 until@fastify/formbodyis registered, before the guard runs. Atext/plainsign-in does reach the guard, with its body unparsed, so it is assessed as unread, as the middleware does it. - A multipart sign-in must be parsed before the guard.
@fastify/multipartleaves a multipart body's fields unparsed onrequest.bodyunless it is registered withattachFieldsToBody: "keyValues"; without that, the sign-in is assessed as unread, never as one with no account. Register it that way:await app.register(multipart, { attachFieldsToBody: "keyValues" }), beforeantibotFastify. - In TypeScript,
request.antibotis not on Fastify's request type unless you declare it: addantibot?: AntibotState(the type is exported from@antibot/node-sdk/fastify) to Fastify'sFastifyRequestinterface.
NestJS runs on either adapter:
- Express adapter. Nest adds its own body parsers when the application
initialises, after anything mounted with
app.use()beforelisten()— so a middleware mounted that way sees no body. Turn Nest's parsers off, and mount the parsers and the guard in that order:
import { NestFactory } from "@nestjs/core"
import express from "express"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotMiddleware } from "@antibot/node-sdk/middleware"
import { AppModule } from "./app.module"
const app = await NestFactory.create(AppModule, { bodyParser: false })
app.use(express.urlencoded({ extended: false }), express.json())
app.use(antibotMiddleware({
client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
logins: [{ path: "/login", field: "email" }],
}))
await app.listen(3000)
- Fastify adapter. Nest's Fastify adapter parses JSON and urlencoded bodies
itself, so
@fastify/formbodyis not needed here. Register the plugin on the adapter beforelisten():
import { NestFactory } from "@nestjs/core"
import { FastifyAdapter } from "@nestjs/platform-fastify"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotFastify } from "@antibot/node-sdk/fastify"
import { AppModule } from "./app.module"
const adapter = new FastifyAdapter()
const app = await NestFactory.create(AppModule, adapter)
await adapter.register(antibotFastify, {
client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
logins: [{ path: "/login", field: "email" }],
})
await app.listen(3000, "127.0.0.1")
The Nest orders above are held by sdk/node/test/nest.test.mjs, a minimal app
on a real Nest (12.3) on either adapter: without bodyParser: false the
sign-in's account is not read — it is assessed as unread, never as absent —
and with it, and on the Fastify adapter, it is.
Next.js, SvelteKit, Remix, Hono: the same, on Request and Response
antibotGuard makes the same decisions from the same code for frameworks built
on the Fetch API. It is tested in Node, and also inside Deno and Bun, each
serving with its own server; those runtimes run Netlify Edge Functions, Deno
Deploy and Bun's own servers. It takes a Request and answers with either:
- a
responseto send in its place, or - the
headersto carry the request on with. These are the visitor's own headers, minus anyantibot-assess-*a client wrote (with underscores too), plus this verdict's (antibot-assess-action,-score,-reasons,-id), the same ones your application reads behind the edge.
// proxy.ts (Next.js 16)
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotGuard } from "@antibot/node-sdk/fetch"
const guard = antibotGuard({
client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY!, endpoint: "https://api.antibot.dev" }),
sessionKeys: process.env.ANTIBOT_SESSION_KEYS!.split(","),
// On Vercel, the address its edge writes; elsewhere, the header your proxy sets.
clientIp: (request) => request.headers.get("x-real-ip"),
protect: ["/shop", "/api"],
logins: [{ path: "/api/login", field: "email" }],
challenge: { publicKey: process.env.ANTIBOT_PUBLIC_KEY!, agentUrl: "/_ab/agent", endpoint: "https://shop.example" },
})
export async function proxy(request: NextRequest, event: NextFetchEvent) {
const decision = await guard(request)
// Receipt usage and fresh revocations, sent and fetched behind the answer.
if (decision.background) event.waitUntil(decision.background)
if (decision.response) return decision.response
return NextResponse.next({ request: { headers: decision.headers } })
}
The SDK verifies receipts with node:crypto, so it needs the Node.js runtime.
A proxy.ts runs on it already, and must not set runtime — Next.js 16
throws if it does. On Next.js 15.5 to 15.x the file is middleware.ts, the
function is middleware, and it opts in with
export const config = { runtime: "nodejs" }.
Served under a basePath, name it as mount: "/app": a configured /login
or protected path is then the request for /app/login too, as the Express
middleware reads paths under its mount.
Then, in a route handler:
const action = request.headers.get("antibot-assess-action")
Where it differs from the Express middleware:
clientIpis required. A Fetch-API runtime has no socket address to fall back on.- The header order is not evidence. A
Headersobject iterates sorted, so the assessment is told so (headersOrdered: false). - The form's field is read from a copy of the body. Your handler still reads the body whole.
- Outcomes are reported from the handler. The guard never sees your
answer, so report a sign-in's outcome there with
client.reportOutcome.
Monitor mode: see what enforcing would do, before it does
Set monitor: true in the options of any Node guard (the Express middleware,
the Fastify plugin, or the Fetch-API guard) to decide every request exactly as
it would be decided, and act on none of those decisions. A request that would
have been refused or challenged goes on to your application, and the state
the application receives says what would have happened: on Express and
Fastify, req.antibot.wouldAct (request.antibot.wouldAct in Fastify); on
the Fetch-API guard, decision.state.wouldAct. It is { action, reason },
where action is "block" or "challenge", and reason is what the guard
would have answered with, such as signal:bot for a receipt convicted of
automation, assess:block for a verdict of block, assess:challenge or
missing:challenge for a challenge. The property is absent when nothing would
have been done. Log it, or check it in your application, to see what enforcing
would have refused before you turn monitor off. Monitor mode is for a site's
first weeks: the risk call is told, so the API keeps those assessments marked
as monitored.
Python
pip install antibot # or: pip install "antibot[sealed]"
import os
from antibot import AntibotClient, AntibotApiError
antibot = AntibotClient(api_key=os.environ["ANTIBOT_SECRET_KEY"],
endpoint="https://api.antibot.dev")
event = antibot.get_event(request_id)
if event["products"]["bot"]["result"] == "bad":
deny()
for e in antibot.iterate_events(linked_id="user_42"):
print(e["requestId"])
Middleware: WSGI and ASGI
The same decisions as the Node middleware, by the same rules, for Flask, Django or anything WSGI, and FastAPI, Starlette or anything ASGI — standard library only.
from antibot import AntibotClient
from antibot.middleware import AntibotASGIMiddleware, AntibotWSGIMiddleware
options = dict(
client=AntibotClient(api_key=os.environ["ANTIBOT_ASSESS_KEY"], endpoint="https://api.antibot.dev"),
session_keys=os.environ["ANTIBOT_SESSION_KEYS"].split(","),
# The header your load balancer or CDN sets — never one a client can write.
client_ip=lambda headers, peer: headers.get("cf-connecting-ip"),
protect=["/shop", "/api"],
logins=[("/login", "email")],
codes=[("/gift-card/redeem", "card")],
contents=[("/reviews", "review.body")],
challenge={"public_key": os.environ["ANTIBOT_PUBLIC_KEY"], "agent_url": "/_ab/agent", "endpoint": "https://shop.example"},
)
flask_app.wsgi_app = AntibotWSGIMiddleware(flask_app.wsgi_app, **options) # Flask
asgi_app = AntibotASGIMiddleware(fastapi_app, **options) # FastAPI
# Django: settings.py — the same options, under ANTIBOT.
MIDDLEWARE = ["django.middleware.security.SecurityMiddleware", "antibot.django.AntibotMiddleware", ...]
ANTIBOT = {**options} # request.antibot in a view
@flask_app.post("/login")
def login():
state = request.environ["antibot"] # scope["antibot"] under ASGI
if state.verdict and state.verdict["action"] == "challenge":
return ask_for_second_factor()
ok = check_password(request.form["email"], request.form["password"])
state.outcome("success" if ok else "failure")
...
A configured form's body is read from a copy and put back, so the application
still reads it whole (32 KB at most for a sign-in or a code, 96 KB for a
message). Under ASGI no more than that is read before the application runs:
a larger body, declared or streamed, is left for it and assessed as unread. Your application can also answer
with an antibot-login or antibot-code response header, which is taken off
before the visitor sees it. Under Django, a configured sign-in that Django's own
authentication decides is reported in its words — authenticate() failing
(user_login_failed) and login() (user_logged_in) — so LoginView's wrong
password, answered with the form again, is counted as the failure it is. A
request's outcome is counted once: the first said stands. Reports go out on a background thread, never in the
response's way. Under ASGI the headers reach the assessment in the order they
arrived; a WSGI environ does not keep that order, and the assessment is told
so (headersOrdered: false). missing="challenge" is
the edge's challenge posture here too. An application served under a prefix
(SCRIPT_NAME under WSGI and Django, root_path under ASGI: /shop) is read
at its own paths as well as the whole one: a configured /login is the request
for /shop/login, and so is /shop/login written whole. The assessed URL keeps
the prefix, and a phone's signed request is checked against it, also when a
proxy took the prefix off before the server and the application is told it
another way (--root-path, FORCE_SCRIPT_NAME, ProxyFix's
X-Forwarded-Prefix).
Monitor mode (monitor=True in these options, or "monitor": True under
ANTIBOT in Django) decides every request as above and acts on none of it: a
refusal, a challenge or a block the middleware would have answered goes on to
the application instead, and state.would_act (request.antibot.would_act
under Django) says what would have been done, as
{"action": "block" | "challenge", "reason": ...}, where the reason is what
would have been used, such as signal:bot, assess:block, assess:challenge
or missing:challenge. It is None when nothing would have been done. A
sign-in the API would block still has its failure reported, and every
assessment says monitor to the API, which keeps it marked as monitored. Use
it in a site's first weeks, to see what enforcing would refuse before it does.
Go
go get github.com/antibot/antibot-go
client, err := antibot.NewClient(os.Getenv("ANTIBOT_SECRET_KEY"), "https://api.antibot.dev")
event, err := client.GetEvent(ctx, requestID)
yes := true
page, err := client.SearchEvents(ctx, antibot.Filters{
Bot: &yes,
Country: []string{"GB", "IE"},
})
Pointer fields keep the tri-states honest: a nil Suspect means unreviewed,
which is not the same as reviewed and clean.
Middleware: net/http
The same decisions as the Node middleware, by the same rules, for any
net/http server, using only the standard library. Wrap your handler:
enforce := antibot.Middleware(antibot.MiddlewareOptions{
// A key allowed to assess and nothing else — not the secret key above.
Client: assessClient, // antibot.NewClient(os.Getenv("ANTIBOT_ASSESS_KEY"), …)
SessionKeys: strings.Split(os.Getenv("ANTIBOT_SESSION_KEYS"), ","),
// The header your load balancer or CDN sets — never one a client can write.
ClientIP: func(r *http.Request) string { return r.Header.Get("Cf-Connecting-Ip") },
Protect: []string{"/shop", "/api"},
Logins: []antibot.FormField{{Path: "/login", Field: "email"}},
})
http.ListenAndServe(":8080", enforce(mux))
In a handler, antibot.StateFrom(r.Context()) returns the verdict and the
receipt (nil where the middleware did not assess). state.Outcome(ctx, "success") reports a sign-in in your own words, and is safe on a nil state.
Without it the outcome is read from your response by the edge's rules
(antibot.LoginOutcome, antibot.CodeOutcome), sent behind the response.
A form's field is read from a copy of the body, up to 32 KiB (96 KiB for a
message), so your handler still reads it whole; a body that cannot be read for
the field is assessed with unreadField, as in Node. The query string is read
for the field beside the body, path parameters (;jsessionid=…) are cut
before paths are matched, and a request in absolute form
(POST http://shop.example/login HTTP/1.1) is matched on its path, as Go
routes it. Inside http.StripPrefix("/shop", …) the guard reads the path your
handlers route as well as the whole one: a configured /login is the request
for /shop/login, and so is /shop/login written whole. The same goes for a
path a handler before the guard rewrote. The assessed URL is the one sent.
The assessment, outcome reports, receipt usage and revocations all run on
context.WithoutCancel(r.Context()), bounded by Timeout (the assessment) or
the client's timeout (the rest). net/http cancels r.Context() as soon as an HTTP/1.1 client half-closes its
connection, so a client could otherwise call its own assessment off — and a
call that fails, fails open. state.Outcome(ctx, …) keeps ctx's values but
not its cancellation.
Set your server's ReadHeaderTimeout and ReadTimeout: a client that sends a
sign-in's body slowly holds the guard's read of it, as it would hold your
handler's r.FormValue, and only the server's timeouts bound either.
An http.Header has lost the order the headers arrived in, so the assessment
is told so. TokenHeader is a *string: nil is antibot-token, a pointer to
"" turns it off. A MaxScore of 0 means the default, 70. See
sdk/go/README.md for every option.
Monitor: true in MiddlewareOptions decides every request as above and acts
on none of it: a refusal, a challenge or a block goes on to your handler
instead, and state.WouldAct (a *antibot.WouldAct, nil when nothing would
have been done) says what would have happened — Action is "block" or
"challenge", and Reason is what would have been used, such as signal:bot,
assess:block, assess:challenge or missing:challenge. A sign-in the API
would block still has its failure reported, and every assessment says
monitor: true to the API, which keeps it marked as monitored. Use it in a
site's first weeks, to see what enforcing would refuse before it does.
Gin, Echo, Chi and Fiber
The middleware above is net/http, so Chi and Echo take it as it is, and Gin
and Fiber take it through a small adapter. Build enforce as in the example
above. Each recipe below was run against a stand-in API, for a refused library
request, an allowed one and a sign-in, with chi v5.2.5, echo v4.13.3, gin
v1.10.1 and fiber v2.52.16 on Go 1.22. Those are the newest releases of each
that build on Go 1.22 (chi v5.3, echo v4.14, gin v1.11 need Go 1.23 or later);
none of them is a dependency of sdk/go.
In every framework the verdict is read the same way, from the request's
context, and is nil where nothing was assessed. authenticate stands for your
own check of the email and password.
Chi takes the middleware as it is:
r := chi.NewRouter()
r.Use(enforce)
r.Get("/shop", func(w http.ResponseWriter, req *http.Request) {
if st := antibot.StateFrom(req.Context()); st != nil && st.Verdict != nil {
fmt.Fprintf(w, "shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID)
}
})
r.Post("/login", func(w http.ResponseWriter, req *http.Request) {
target := "/login?error=1"
if authenticate(req.FormValue("email"), req.FormValue("password")) {
target = "/shop"
}
http.Redirect(w, req, target, http.StatusFound)
})
Echo takes it through echo.WrapMiddleware. A refusal writes its 403 and
does not call the next handler, and the handler reads the verdict from
c.Request().Context():
e := echo.New()
e.Use(echo.WrapMiddleware(enforce))
e.GET("/shop", func(c echo.Context) error {
if st := antibot.StateFrom(c.Request().Context()); st != nil && st.Verdict != nil {
return c.String(http.StatusOK, fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
}
return c.NoContent(http.StatusOK)
})
e.POST("/login", func(c echo.Context) error {
target := "/login?error=1"
if authenticate(c.FormValue("email"), c.FormValue("password")) {
target = "/shop"
}
return c.Redirect(http.StatusFound, target)
})
Gin needs an adapter, because Gin's handlers write to c.Writer, and the
middleware reads a sign-in's answer from what the handler wrote. The adapter
hands the middleware's writer to the handler, aborts the chain when the
middleware answers by itself, and hands the request on carrying the state:
package main
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
)
// AntibotGin runs a net/http middleware around the rest of a Gin chain.
func AntibotGin(mw func(http.Handler) http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {
passed := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
passed = true
own := c.Writer
c.Request = r
c.Writer = &ginWriter{ResponseWriter: own, to: w}
c.Next()
c.Writer = own
})).ServeHTTP(c.Writer, c.Request)
if !passed {
c.Abort()
}
}
}
// ginWriter sends the handler's writes to the writer the middleware handed on
// (which records the status and Location of a sign-in's answer), and keeps
// Gin's writer for everything else: Status, Size, Hijack, WriteHeaderNow.
type ginWriter struct {
gin.ResponseWriter
to http.ResponseWriter
}
// WriteHeader forwards only a status: Gin calls WriteHeader(-1) to mean "keep
// the status" (c.Redirect does), and Gin itself ignores any code of 0 or less.
func (g *ginWriter) WriteHeader(code int) {
if code > 0 {
g.to.WriteHeader(code)
}
}
func (g *ginWriter) Write(b []byte) (int, error) { return g.to.Write(b) }
func (g *ginWriter) WriteString(s string) (int, error) {
return io.WriteString(g.to, s)
}
func (g *ginWriter) Flush() {
if f, ok := g.to.(http.Flusher); ok {
f.Flush()
}
}
Then, with AntibotGin in the same package:
r := gin.New()
r.Use(AntibotGin(enforce))
r.GET("/shop", func(c *gin.Context) {
if st := antibot.StateFrom(c.Request.Context()); st != nil && st.Verdict != nil {
c.String(http.StatusOK, fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
}
})
r.POST("/login", func(c *gin.Context) {
target := "/login?error=1"
if authenticate(c.PostForm("email"), c.PostForm("password")) {
target = "/shop"
}
c.Redirect(http.StatusFound, target)
})
The c.Abort() is what stops the chain: without it Gin ran the handler after
the 403 and its output followed the refusal. The redirect's outcome is read
because the handler's writes go through the middleware's writer.
Fiber runs on fasthttp, so the middleware goes in through
adaptor.HTTPMiddleware. The adaptor runs the middleware before the handlers
after it, so the middleware cannot read a sign-in's response; without the
observeLogin handler below no outcome is reported. observeLogin reads the
status and Location the route wrote and reports them with State.Outcome
(antibot.LoginOutcome is the same rule the middleware uses). The refusal,
the verdict and the body of a sign-in all work through the adaptor unchanged.
app := fiber.New()
app.Use(adaptor.HTTPMiddleware(enforce))
app.Use(observeLogin())
app.Get("/shop", func(c *fiber.Ctx) error {
if st := antibot.StateFrom(c.Context()); st != nil && st.Verdict != nil {
return c.SendString(fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
}
return c.SendStatus(fiber.StatusOK)
})
app.Post("/login", func(c *fiber.Ctx) error {
target := "/login?error=1"
if authenticate(c.FormValue("email"), c.FormValue("password")) {
target = "/shop"
}
return c.Redirect(target, fiber.StatusFound)
})
// observeLogin reports a sign-in's outcome from the response a route wrote.
func observeLogin() fiber.Handler {
return func(c *fiber.Ctx) error {
err := c.Next()
st := antibot.StateFrom(c.Context())
if st == nil {
return err
}
res := c.Response()
outcome := antibot.LoginOutcome(
res.StatusCode(),
string(res.Header.Peek(fiber.HeaderLocation)),
string(res.Header.ContentType()),
c.Path(),
)
if outcome != "" {
_ = st.Outcome(c.Context(), outcome)
}
return err
}
}
The client address. ClientIP receives the *http.Request, not the
framework's context, so it cannot call c.ClientIP(), c.RealIP() or
c.IP(). Behind a proxy, return the header your proxy sets, as in the
net/http example. The two can disagree: in Gin, c.ClientIP() takes
X-Forwarded-For only from a proxy listed with SetTrustedProxies, and with no
ClientIP option the SDK assesses the connection's own address. In a test with
127.0.0.1 as a trusted proxy and X-Forwarded-For: 203.0.113.9, c.ClientIP()
gave 203.0.113.9 and the assessment carried 127.0.0.1.
Java
Java 17+. No build tool required — the sources compile with javac directly.
var antibot = new AntibotClient(System.getenv("ANTIBOT_SECRET_KEY"),
"https://api.antibot.dev");
Map<String, Object> event = antibot.getEvent(requestId);
The client pins HTTP/1.1. The JDK defaults to HTTP/2, which over cleartext
sends an h2c upgrade preface, and an origin speaking only HTTP/1.1 closes the
connection rather than declining — surfacing as header parser received no bytes.
Enforcing in your server: the guard and a Servlet filter
AntibotGuard makes the Node middleware's decisions, by the same rules, in plain
JDK code. AntibotFilter runs the guard as a Jakarta Servlet 6 filter (Tomcat
10.1+, Jetty 12, Spring Boot 3). The filter lives in src-servlet/, so the
client still compiles with javac alone. In Spring Boot, the
starter registers the client, guard and filter from antibot.*
properties, in the right order against Spring Security.
AntibotGuard guard = new AntibotGuard(new AntibotGuard.Options(
new AntibotClient(System.getenv("ANTIBOT_ASSESS_KEY"), "https://api.antibot.dev", Duration.ofMillis(800)))
.sessionKeys(List.of(System.getenv("ANTIBOT_SESSION_KEYS").split(",")))
// The header your load balancer or CDN sets — never one a client can write.
.clientIp(request -> request.header("cf-connecting-ip"))
.protect("/shop", "/api")
.logins(new AntibotGuard.FormField("/login", "email")));
servletContext.addFilter("antibot", new AntibotFilter(guard)).addMappingForUrlPatterns(null, false, "/*");
In Spring Boot, register the filter with an order, ahead of Spring Security:
@Bean
FilterRegistrationBean<AntibotFilter> antibotFilter(AntibotGuard guard) {
FilterRegistrationBean<AntibotFilter> registration = new FilterRegistrationBean<>(new AntibotFilter(guard));
// After the encoding and forwarded-header filters, before Spring Security (-100).
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
return registration;
}
Don't declare the filter itself as a @Bean, or use a registration without an
order: Boot then orders it last, after Spring Security has answered the
sign-in, and no form login is ever assessed.
A handler finds the verdict and the receipt with AntibotFilter.stateOf(request),
and reports a sign-in with outcome("success"). Without that call, the
outcome is read from the response.
A configured form's body of any type is read up to the cap and replayed to
your application whole: through getInputStream, getReader,
getParameter, and for multipart getParts. A body that cannot be read for
the field — past the cap, a type not parsed, JSON that is not JSON, an object
where the value goes, a file part, the field sent twice, or a client gone
mid-body — is assessed with unreadField. The field is read as
getParameter reads it, query string included. Forms and prefixes match the
path as the application may read it, ;jsessionid=… and all. A servlet
container does not promise the order headers arrived in, so the assessment is
told so. See sdk/java/README.md for
every option.
Monitor mode, for a site's first weeks: monitor(true) on the guard's options
decides every request as usual and acts on none of it. Where it would refuse or
challenge, the request goes on to the handler, and State.wouldAct() says what
would have been done and why (block or challenge, with signal:bot,
assess:block, missing:challenge, ...). Assessments carry monitor: true, so
the API keeps them as monitored, and outcomes are still reported.
PHP
composer require antibot/antibot-php
$antibot = new \Antibot\AntibotClient(getenv('ANTIBOT_SECRET_KEY'),
'https://api.antibot.dev');
$event = $antibot->getEvent($requestId);
foreach ($antibot->iterateEvents(['linked_id' => 'user_42']) as $e) {
echo $e['requestId'];
}
The client sends with curl. Where your platform has its own way, pass it as
the fourth argument — given the method, the URL, the header lines, the body
(null for none) and the timeout in seconds, it answers [status, body] and
throws when no answer came; the WordPress plugin sends through
wp_remote_request() this way. \Antibot\Guard::forgetShared($endpoint, $key)
removes what guards for that endpoint and key keep between requests (a list
file in the system's temporary directory, and APCu's entries), for when an
application takes the integration out.
Enforcing every request: plain PHP and PSR-15
Antibot\Guard makes the Node middleware's decisions, by the same rules. It
needs no framework.
$guard = new \Antibot\Guard(
client: new \Antibot\AntibotClient(getenv('ANTIBOT_ASSESS_KEY'), 'https://api.antibot.dev', 2),
sessionKeys: explode(',', getenv('ANTIBOT_SESSION_KEYS')),
// The header your load balancer or CDN sets — never one a client can write.
clientIp: fn (array $headers) => $headers['cf-connecting-ip'] ?? null,
protect: ['/shop', '/api'],
logins: [['path' => '/login', 'field' => 'email']],
);
// At the top of the front controller: a refusal is sent and the script exits.
$antibot = $guard->enforceGlobals();
How you use the result:
- Classic PHP.
enforceGlobals()returns the state:$antibot->verdict,$antibot->receipt, and$antibot->outcome('success')to report a sign-in. Without that call, the outcome is read from your response once the script ends. - PSR-15 (Slim, Mezzio, Laminas). Add
new \Antibot\AntibotMiddleware($guard, $responseFactory, $streamFactory); the state is the request'santibotattribute. The PSR packages are suggested, not required, and the guard never loads them. - Laravel. The
antibot/laravelpackage is this guard as middleware, with$request->antibot()for the route. - Symfony. The
antibot/symfonybundle is this guard on the kernel's request event, with the state in the request'santibotattribute. - Anything else.
$guard->decide([...])takes the request as an array and returns aGuardResponseto send or theGuardStateto go on with.
Forms can also be a callable, forms: fn (string $method, string $path, array $request): ?array returning ['kind' => …, 'field' => ?string], for a site
that recognises its forms by script, query or posted fields rather than by
path (the WordPress plugin does); a null field is a form with
nothing to read — always assessed, its challenge going on to the application.
The state carries ->ip and ->field, as assessed, for an outcome reported
later. Field names are read as PHP reads them (user[email] is user.email).
In classic PHP a multipart body is read from $_POST/$_FILES, since
php://input never holds it, so a field PHP has already collapsed is read as
PHP kept it. Receipt usage and revocations are pooled between requests with
APCu; without it, classic PHP sends each request's usage as it ends, and keeps
the revocation list in a file in the system's temporary directory, signed with
a key derived from the secret key (a file it did not sign is read as none),
fetched again by the one request that finds it fifteen seconds old, after its
response. Long-lived workers call
$guard->upkeep() after each response (the PSR-15 middleware does).
PHP does not reliably keep the order headers arrived in, so the assessment is
told so. See sdk/php/README.md for every option.
Monitor mode (monitor: true, a named argument of Guard) decides every
request as it would be decided, and acts on none of those decisions: a refusal
or a challenge goes on to your application, and the GuardState it receives
has wouldAct, ['action' => 'block' | 'challenge', 'reason' => …] (the
reason the guard would have answered with, such as signal:bot or
assess:block), or null when nothing would have been done. Sign-in outcomes
are still reported, and each assessment is sent with monitor: true, so the
API keeps it as what would have been done, not as done. Use it for a site's
first weeks, then turn it off.
.NET
dotnet add package Antibot
using var antibot = new AntibotClient(
Environment.GetEnvironmentVariable("ANTIBOT_SECRET_KEY"),
"https://api.antibot.dev");
var evt = await antibot.GetEventAsync(requestId);
await foreach (var e in antibot.IterateEventsAsync(
new Dictionary<string, object?> { ["linked_id"] = "user_42" }))
{
Console.WriteLine(e.GetProperty("requestId").GetString());
}
Middleware: ASP.NET Core
Antibot.AspNetCore makes the Node middleware's decisions, by the same rules, in
the ASP.NET Core pipeline. It is a package of its own, so the client keeps no
ASP.NET Core dependency.
builder.Services.AddSingleton(new AntibotClient(Environment.GetEnvironmentVariable("ANTIBOT_ASSESS_KEY")!, "https://api.antibot.dev"));
builder.Services.AddAntibot(options =>
{
options.SessionKeys = Environment.GetEnvironmentVariable("ANTIBOT_SESSION_KEYS")!.Split(',');
// The header your load balancer or CDN sets — never one a client can write.
options.ClientIp = context => context.Request.Headers["CF-Connecting-IP"];
options.Protect = new[] { "/shop", "/api" };
options.Logins = new[] { new AntibotFormField("/login", "email") };
});
var app = builder.Build();
app.UseAntibot();
In a handler, context.Antibot() gives the verdict and the receipt — null
where the middleware did not protect the request — and
if (context.Antibot() is { } antibot) await antibot.OutcomeAsync("success");
reports a sign-in in your own words.
Without it, the outcome is read from the response by the edge's rules once the
response has gone. With ASP.NET Core Identity, let Identity say it:
builder.Services.AddDefaultIdentity<IdentityUser>().AddAntibotSignIns<IdentityUser>()
makes its sign-in manager report each password sign-in on a configured form —
a wrong password (which its login page answers with the page again, a 200 that
says nothing) and a lockout as failures, a success once the visitor is signed
in, and nothing while a second factor is still to come, so a device that knew
the password and not the second factor is never remembered as the account's.
A sign-in manager of your own derives from AntibotSignInManager<TUser>.
A form's field is read from a buffered copy of the body, rewound so your
endpoint still reads it whole; a body that cannot be read for it is assessed
with unreadField, as in Node. Field names are matched ignoring case, as
Request.Form and ASP.NET Core's JSON binding match them, so EMAIL= is the
email field and email=…&EMAIL=… is the field sent twice. The query string
is read beside the body, as ASP.NET Core's model binding reads it (a minimal
API's string email comes from ?email=), and path parameters are cut
before paths are matched. Form names are read as model binding reads
them: with Input.Email configured (ASP.NET Core Identity's login page),
Email, model.Email and Input[Email] are the field too, in any case, and
two different values among them are not believed. Under UsePathBase,
configure paths as your routes see them (/login): that covers /app/login
and /login alike. Timeout (2 seconds) bounds the assessment, whatever
HttpClient the client has; past it the request goes on. The calls behind the
answer are given it or the client's 10 seconds, whichever is longer. A
large chunked upload to a form path is streamed on to your application, never
spooled to disk. Nothing the
middleware decides waits on RequestAborted, which Kestrel fires when a client
half-closes its side of the connection: a sign-in sent that way is still read
(or, if its body never arrived, assessed as unread), assessed and answered —
bounded by the client's own timeout — never let through unassessed, and a
refusal that cannot be written is still never passed on. ReportReceiptUsageAsync and
RevocationsAsync are on the client for a verifier of your own. ASP.NET Core
does not keep the order headers arrived in, so the assessment is told so. The URL's scheme comes from
Request.IsHttps, which makes it right behind UseForwardedHeaders. See
sdk/dotnet/README.md for every option.
Monitor mode, for a site's first weeks: Monitor = true in the middleware's
options decides every request as usual and acts on none of it. Where it would
refuse or challenge, the request goes on to the application, and
context.Antibot().WouldAct says what would have been done and why (block or
challenge, with signal:bot, assess:block, missing:challenge, ...).
Assessments carry monitor: true, so the API keeps them as monitored, and
outcomes are still reported.
Ruby
Ruby 3.1+. Standard library only.
gem install antibot
antibot = Antibot::Client.new(api_key: ENV.fetch("ANTIBOT_SECRET_KEY"),
endpoint: "https://api.antibot.dev")
event = antibot.get_event(request_id)
antibot.iterate_events(linked_id: "user_42") { |e| puts e["requestId"] }
Answers are Hashes with String keys. A non-2xx answer raises
Antibot::ApiError (status, code); the API out of reach raises
Antibot::ConnectionError. timeout: (10 s) bounds a whole call, including a
429's Retry-After wait, which is retried (max_retries:, 2) only when it fits.
Middleware: Rack (Rails, Sinatra, Hanami)
Antibot::Middleware makes the Node middleware's decisions, by the same rules,
in any Rack application; its answers are the Node guard's, byte for byte.
# config/application.rb — first, before anything reads the body
config.middleware.insert_before 0, Antibot::Middleware,
client: Antibot::Client.new(api_key: ENV.fetch("ANTIBOT_ASSESS_KEY"), endpoint: "https://api.antibot.dev", timeout: 2),
session_keys: ENV.fetch("ANTIBOT_SESSION_KEYS").split(","),
# The header your load balancer or CDN sets — never one a client can write.
client_ip: ->(env) { env["HTTP_CF_CONNECTING_IP"] },
protect: ["/shop", "/api"],
logins: [{ path: "/users/sign_in", field: "user[email]" }],
challenge: { public_key: ENV.fetch("ANTIBOT_PUBLIC_KEY"), agent_url: "/_ab/agent", endpoint: "https://shop.example" }
In a controller, request.env["antibot"] is the state: verdict (the
assessment) and receipt (the verified claims), nil where the request was not
assessed.
def create
antibot = request.env["antibot"]
return ask_for_second_factor if antibot.verdict&.dig("action") == "challenge"
user = User.authenticate_by(email: params[:email], password: params[:password])
antibot.outcome(user ? "success" : "failure")
end
Sinatra, Hanami or a config.ru: use Antibot::Middleware, client: ….
Without outcome, a sign-in's outcome is read from your response by the
edge's rules, or from an antibot-login / antibot-code response header,
which is taken off before the visitor sees it.
A form's field is read from a copy of the body (32 KiB, 96 KiB for a message)
put back for your application to read whole, and from the query string, as
Rails' params merges the two. A body that cannot be read for the field —
including one whose read fails because the client went away — is assessed with
unreadField. The path is SCRIPT_NAME + PATH_INFO, so an absolute-form
request is its path; an application mounted under a prefix (map "/shop") is
read at its own paths as well, so a configured /login is the request for
/shop/login, and so is /shop/login written whole. A format suffix is a path
of its own: Rails routes /orders.json to the action /orders names, so protect
a prefix both share, or name each. A Rack env does not keep the order headers arrived in, so
the assessment is told so. Outcome reports, receipt usage and revocations run
on background threads, never delaying a response. See sdk/ruby/README.md
for every option.
Monitor mode (monitor: true in the Antibot::Guard options, or the
middleware's) decides every request as it would be decided, and acts on none of
those decisions: a refusal or a challenge goes on to your application, and
env["antibot"].would_act says what would have been done, { action:, reason: }
(nil when nothing would have been). Sign-in outcomes are still reported, and
each assessment is sent with monitor: true, so the API keeps it as what would
have been done, not as done. Use it for a site's first weeks, then turn it off.
Why responses are maps, not typed models
Every client returns decoded maps or JsonElement rather than a fixed class.
The signal vocabulary grows server-side, and a struct compiled last year would
silently drop anything added since. You get the whole payload, always.