Production checklist
Most of this system is safe by default. The parts that are not are the ones that depend on what is in front of it, or on there being only one copy of it. This is those parts in one place, with what goes wrong if you skip each. Launching is the order to do them in.
Most of it is checked for you. pnpm doctor (in web/) reads a deployment's
settings — this shell's, or a file's with --env-file .env.production, and
--serverless to judge them as on Vercel — and names each one missing or wrong
with what that costs; it also checks that the database answers and how quickly,
that this build's migrations are the ones applied (not a newer release's), that
the directory's key is the one it was made with, that the address databases and
blocklists exist, and that no legal page still has a placeholder. It exits 1 on
an error, so a deploy can stop on it. A production instance logs the same
findings when it starts, each error as [antibot] production checklist: …
(on AWS, the checklist-error alarm).
Before the first deploy
| Do | Why |
|---|---|
Set ANTIBOT_CLIENT_IP_HEADER to the header your platform sets itself (cf-connecting-ip, x-vercel-forwarded-for, fly-client-ip, or x-forwarded-for with ANTIBOT_TRUSTED_PROXY_HOPS) |
Unset, the client's address is read from whichever forwarding header is present, and any client can send every one of them. They then choose their own address — a clean residential one — and with it the geolocation, VPN/proxy/Tor, velocity and impossible-travel signals and every per-address rate limit. Vercel and Fly are detected; Cloudflare in front of either is not (name cf-connecting-ip yourself). The app logs a warning the first time it falls back. See Which header to believe. |
Point DATABASE_URL at Postgres (postgres://…?sslmode=verify-full), a database made with LC_COLLATE 'C' |
The default is PGlite, a database inside one process. Challenges, waiting rooms, session state, events and rules are all rows, so any number of instances can share them — but only on a shared database. The app refuses to migrate a database that compares text by a language's rules (id ranges and pages assume byte order), and a remote one reached without TLS is named by the doctor. |
Set ANTIBOT_VALKEY_URL (ElastiCache Serverless: rediss://…, with ANTIBOT_VALKEY_CLUSTER=1) |
Without it, every identification writes its integrity key and spent nonce, and every sign-in its rate-limit windows, to the database: correct on any number of instances, but writes the database spends on state worth nothing after minutes. |
Before publishing /legal/terms, /legal/privacy, /legal/dpa and /legal/cookies: have counsel review them, fill every [BRACKETED] placeholder (web/content/legal/*.md), and set ANTIBOT_LEGAL_REVIEWED at build |
Until then each page says it is a draft not in force — the bracketed company name, address, jurisdiction and contacts are not filled in, and the cookie notice's demonstration behaviour has to be chosen. |
Charging for the hosted service: set STRIPE_SECRET_KEY, STRIPE_PRICE_PRO, STRIPE_PRICE_PRO_METERED and STRIPE_WEBHOOK_SECRET, and point a Stripe webhook at /api/billing/stripe (Billing) |
Without STRIPE_SECRET_KEY there is no billing: every organization is served without limit, trial or not. With it but no webhook, a subscription paid in Stripe never reaches the organization, which stays on its trial and stops being served when the trial ends. |
Serving more than one data region: set ANTIBOT_REGION, ANTIBOT_REGIONS, and the shared directory (ANTIBOT_DIRECTORY_URL, a Postgres every stack reaches, and ANTIBOT_DIRECTORY_KEY, the same key in every stack) in every regional stack |
Each region is a whole stack with its own database (Deployment architecture). Without the directory, the same address can open an account in two regions, and a person who signs in at the wrong one is told their password is wrong instead of being sent to their region. Without ANTIBOT_REGIONS, the sign-up page offers no choice and the integration page gives the request's own host. Opening a second region after running one: set the directory on the first stack too, and open the new region's sign-ups once the first logs antibot: directory: region <r> recorded in full (Deployment architecture). |
Point ANTIBOT_IP_MMDB and ANTIBOT_ASN_MMDB at local databases (or set IPINFO_TOKEN, or ANTIBOT_IP_LOOKUP=off) |
Without any of them, every new visitor address is looked up on ipapi.co's free tier: each one is sent to that service — a third party your privacy notice may not name — and past about a thousand a day it answers no more, so new addresses go unknown (country, network, datacenter). Local MaxMind DB files (GeoLite2, or DB-IP's free Lite databases) answer every lookup on the server itself, with no limit; pnpm ip:databases <dir> fetches DB-IP's, and run monthly it keeps them current with no restart (DB-IP asks for attribution: "IP Geolocation by DB-IP"). With a token addresses go to ipinfo.io under your own contract; off sends none anywhere and uses only what is cached, so those readings go quiet for a new address. |
Set ANTIBOT_BLOCKLISTS=fetch on a long-running server (the service fetches them itself, once a day for the deployment), or point ANTIBOT_BLOCKLISTS_DIR at a directory pnpm blocklists <dir> fills daily |
Without it ip_blocklist knows only Tor exits, the address's own network and your threat feed. With it, Spamhaus DROP's criminal netblocks and IPsum's attack sources are looked up on the server itself, nothing sent, and email.disposable knows some four thousand throwaway-inbox domains rather than 111; credit Spamhaus where you list your data sources ("Spamhaus DROP data, The Spamhaus Project"). |
Set IP_PSEUDONYM_KEY |
Without it a key is generated and stored in the same database as the digests it protects, so a reader of the database also holds the key. |
Set ANTIBOT_SERVICE_IPS to the addresses your API answers on, if customers use their own subdomains |
Customers point an A record at these to serve identifications from a subdomain of their own; unset, the dashboard tells them documentation addresses (203.0.113.10, .11) that reach nothing. web/.env.example lists every setting. |
Letting shopping agents through checkout: name the card networks' agent registries in ANTIBOT_TRUSTED_AGENT_ISSUERS (Visa=https://…/keys/{keyid}, comma-separated, https only) |
Without it a signature tagged by the Trusted Agent Protocol (agent-browser-auth, agent-payer-auth) is not checked, and the agent is judged as any client is. With it, a key the network's registry holds proves the agent, and rules read what it is doing (agent.intent). The URL is the network's, from its developer programme; pnpm doctor names an entry it had to leave out. |
Set ANTIBOT_DB_NAMED_STATEMENTS=off only if a pooler in transaction mode sits between the app and Postgres (PgBouncer; RDS Proxy) |
The hot path's statements are prepared by name, parsed once per connection. A pooler that hands a session's statements to another connection breaks that (PgBouncer in transaction mode), or pins every connection that holds one (RDS Proxy). Off, the SQL is still built once and Postgres parses it each time. Connecting straight to Aurora, as the deployment architecture does, leave it unset. On, each connection asks Postgres, as it opens, to plan every statement for its values (SET plan_cache_mode = force_custom_plan): a plan kept for any values read thousands of rows where a dozen do. |
Leave ANTIBOT_SEED_DEMO unset |
Production never seeds the demo accounts; the variable is only for a staging box that should. |
Leave ANTIBOT_TEST_DATABASE_URL unset |
The app uses the database it names in place of DATABASE_URL: a tool for running the test suites on a real Postgres (pnpm test:suites-postgres), nothing else. pnpm doctor refuses it. |
Leave ANTIBOT_SQL_LOG unset |
It writes every statement the app runs, with its parameters (addresses, visitor ids), to a file: a tool for the query-plan audit and its test, nothing else. pnpm doctor refuses it. |
Set NEXT_PUBLIC_DEMO_KEY if the marketing pages should identify visitors live |
With no demo organization and no key, the home page and playground show a placeholder instead of a live identification. Use a public key from an organization of your own — every visitor to the marketing site, bots included, is identified into it. |
Set mail (CLOUDFLARE_EMAIL_TOKEN and CLOUDFLARE_EMAIL_ACCOUNT_ID, or RESEND_API_KEY), ANTIBOT_MAIL_FROM (an address on a domain verified with that provider) and ANTIBOT_PUBLIC_URL before opening sign-up to the public |
With all three, a password sign-up confirms its address by a link before the account is used, and a forgotten password is reset by email (an hour's link; it signs the account out everywhere and leaves two-factor on). Owners and admins are also mailed what needs them: an attack alert, new scripts on a watched page, and a webhook failing for an hour or a log stream for a quarter of an hour (both also in the audit log). Without them nothing can be proved: an account works the moment it is made, by anyone's address, and nobody can get back into an account whose password they forgot. |
Set ANTIBOT_PUBLIC_URL (e.g. https://app.example.com) if the app sends email |
It is the only source production trusts for the links in emails. Unset, invitations are still created but not mailed — the inviter is shown the link to pass on — and the log says why: the request's Host / X-Forwarded-Host is chosen by whoever sends it, and an invitation built from it once pointed a genuine email at the sender's own site. |
If people sign in through single sign-on, set ANTIBOT_PUBLIC_URL and keep each domain's _antibot-challenge TXT record |
The redirect URI the provider sends people back to is built from it and nothing else; unset, single sign-on cannot start. The TXT record is what lets the provider speak for addresses on the domain. See Single sign-on. |
| Turn on two-factor sign-in for every owner and admin (Security page) | The dashboard mints secret keys, writes the rules that decide what is blocked and purges data. A password alone is what credential stuffing breaks. See Two-factor sign-in. |
Put a proxy that absorbs floods in front — Cloudflare, proxied — and let the load balancer take only its addresses; set an AWS budget and the flood alarm (monthly_budget_usd, flood_requests_per_minute: Floods and the bill) |
The app refuses each address past its limits, but a flood from many addresses, each under them, still reaches every instance: with nothing in front it slows the service for every customer, and the load balancer bills each request of it. The budget and the flood alarm say when either is happening. A customer's bill is told by the identifications alert. |
| Terminate TLS at the platform | The app sends Strict-Transport-Security: max-age=31536000 on its pages when the platform says the request came over HTTPS (X-Forwarded-Proto: https), and never on /api or /agent, which a customer's first-party proxy serves under its own name. includeSubDomains and preloading are the domain owner's to add at the platform. |
Point your orchestrator at /api/health (liveness) and /api/ready (readiness), and set ANTIBOT_METRICS_TOKEN for Prometheus to scrape /api/metrics with as a bearer |
Liveness asks nothing of the database, so a slow database never restarts the API. Readiness answers 503 until the database replies within a second, so a new instance gets traffic only once it can serve it. Valkey it times and says (valkey: "unavailable"), never a 503: while Valkey does not answer, the database stands in for it — rate windows, spent nonces and integrity keys kept in hot_state until a ping every five seconds finds Valkey again, and what was kept there meanwhile still found after — and a Valkey no instance reaches would otherwise take them all out of the load balancer at once. The scrape shows assessments and identifications by verdict, their latency as histograms (an edge waits 250 ms by default), webhook deliveries and log stream lines by outcome, calls the database answered because Valkey did not (antibot_hot_store_stand_in_total, by operation: alarm on it, the database is taking Valkey's writes) and reads answered without the store at all (antibot_hot_store_failures_total: velocity reads as none), each database pool's connections as they stand (antibot_db_pool_connections, pool app or directory, state open, idle or waiting: alarm on waiting above zero for a minute — every request queues behind it), connections closed because the database said nothing past the statement timeout and its margin (antibot_db_silent_connections_total: a failover that left sockets open), and the process's memory. It names no customer, and it is off (404) without the token, except on a development server. Every figure is per process; the scraper sums the instances. |
Set ANTIBOT_SENTRY_DSN (a Sentry project's DSN) and/or ANTIBOT_ERROR_WEBHOOK_URL (a Slack-compatible incoming webhook) |
Every error a request ends in — a page that fails to render, a route or a server action that throws — and every background job that fails is told there, with Next's digest (the code the person who hit it was shown), the route as written (/invite/[token], never the token), the region and the release (Vercel's commit, else ANTIBOT_RELEASE, which infra/aws sets to the image's tag). No header, cookie, body, path or query string is sent, and the message and stack are cleaned of a failed query's parameters, of a URL's credentials, of LayerSift keys (pk_/sk_/mk_), of bearer tokens and of email addresses, percent-encoded ones too. One error over and over is told once every five minutes per instance, and no instance tells more than twenty a minute. Unset, errors are only logged. |
Protect your own sign-up and sign-in with the service: create an organization for your own traffic, add your app's origins to its allowed origins, and set ANTIBOT_SELF_PROTECT_SECRET_KEY, ANTIBOT_SELF_PROTECT_PUBLIC_KEY (and ANTIBOT_SELF_PROTECT_API when it lives on another host) |
A free trial is what fake accounts are made for, and a dashboard's sign-in is what credential stuffing is aimed at. The sign-in pages load the agent; each sign-up and sign-in is assessed first and refused on block, and each sign-in's outcome is reported, so one address working down a list of accounts is seen. A challenge goes on: a new account still proves its address before it is served. It fails open — an API that does not answer within 1.5 s lets the person through, and the failure is reported (above). |
Watch /api/ready of every region from an uptime service outside your infrastructure (Better Stack, Instatus, Checkly…), publish its status page, and set ANTIBOT_STATUS_URL to it |
The site's footer links to it. A status page served by the service itself goes down with what it reports on. |
On a serverless platform (instances that end with their requests), set CRON_SECRET and call /api/cron every five minutes with Authorization: Bearer $CRON_SECRET (Vercel Cron sends it for you) |
Webhook retries, the last log stream lines of a quiet site, the end of traffic alerts, the retention sweep, usage reported to billing and the daily checks of page scripts and custom domains run on timers in a long-lived process; where no process lives between requests, they never fire — retries never retried, expired data never deleted. The route runs each once and says how it went; it is safe beside the timers and on many instances at once. Without CRON_SECRET it does not exist (404). |
| Create session keys, and a proxy secret if you use the CDN proxy | Session tokens are signed with the organization's session keys; the proxy's forwarded address and transport evidence are honoured only with its secret. See First-party integration. |
How much one instance serves
Node runs an instance's JavaScript on one core, so an instance is a process: run one
per core and put the database they share behind them. Measured on a development
machine (Windows, the in-process server against a Postgres on the same machine,
2026-10-27, probes/latency.mjs): a navigation assessment one at a time takes a
median of 11 ms (p99 14 ms), a sign-in naming an account 12 ms (p99 35 ms) —
the sign-in counts the account's attempts and devices as well; sixteen in flight,
each from an address not seen before, about 175 a second at a median of 87 ms
(p99 134 ms). On SQLite in the same process, before the move to Postgres, it was
about 230 a second, and past about 40 in flight the slowest passed the 250 ms an
edge waits by default (the edge then fails open). Identifications cost more — the
device is matched against the ones seen before — and the costly case is a device
never seen, which is every request of a bot that draws a new fingerprint each
time, compared against up to 400 others of its configuration: one at a time a
median of 22 ms, 44 a second; eight in flight, 83 a second (median 93 ms);
thirty-two, 90 (on SQLite: about 60 a second, its one writer the limit). Of an
identification's CPU, about a fifth is the ORM building statements — an event's
insert alone, its fifty columns, a quarter of a millisecond — which moves off the
request with the events (docs/deployment-architecture.md, Phase 3). None of it grows with the history kept: every
lookup an identification makes is by index, and the counts a device is measured
against (how common its configuration is, how busy it is beside the rest) are
taken every fifteen and five minutes per instance.
Measured again once identification stopped reading its history back from the identifications (2026-10-27; the same machine, the database as the short-lived store): a device never seen, 18 ms one at a time, about 82 a second at eight in flight and 80 at thirty-two (87 with Garnet, a Valkey-compatible server, as the store). A device seen 2,000 times already — a farm of identical browsers, each request from a new address — 59 a second one at a time (16 ms) and 103 at eight in flight with a p99 of 89 ms, where reading its history back gave 50 and 79 (p99 237 ms). An address 2,000 identifications deep, new devices on it: the same throughput as before (58 and 88 a second), the slowest faster (p99 108 ms against 169).
Storage grows with identifications: each keeps its signals and the device's
components, deflated — about 10 KB from a real browser, measured, where the
JSON is 22 KB — so a million a day is about 10 GB a day. They are kept until deleted unless the organization sets
eventRetentionDays (privacy):
set one before traffic arrives. The session pulse keeps only the beacons that
are evidence — a quiet tab's every fifteen seconds is counted, not stored.
probes/latency.mjs measures it on yours, one at a time and with sixteen in
flight; a remote database adds its round trip to each of the dozen or so
statements an assessment runs.
What assumes there is only one copy
These live in process memory. On one instance they are exactly right. On several they are approximate, and nothing breaks:
| State | With more than one instance |
|---|---|
| Traffic rate limits (identify, challenge, pulse, queue) | Each instance counts separately, so the real limit is instances × the stated one. They are brakes on volume, and a database write per identification would cost latency on a remote database. (They count per source — an IPv4 address or an IPv6 /64, and an IPv6 source's /48 too at sixteen times the limit, grown by the devices that ran the agent there as a crowd's address is — so a server cannot multiply them by rotating through its own block, nor a tunnel broker's /48 by its sixty-five thousand /64s.) |
| The copies of rules, request filters and the threat feed every request reads | Held five seconds per instance. A change made through the dashboard or the Management API applies at once on the instance that made it and within five seconds on the others. API keys and organization settings are not held: a revoked key stops at once on every instance. |
| The Web Bot Auth key-directory cache and its fetch allowances | Per instance: each fetches and counts for itself. Harmless, just more fetches. |
| Session-key and similar caches | Refreshed within a minute; a key rotation takes up to that long to be seen everywhere. |
| How often each rule and request filter matched | Gathered by each instance and added to the database every five seconds, and before an instance stops: the dashboard's count runs up to five seconds behind, and an instance that crashes loses its last five. A flood a filter turns away is not a write a request. |
The limits that are the protection are shared: sign-in attempts (per account and per source), second-factor codes (signing in and managing the factor) and lookups against the breached-credential corpus count in the database, with one atomic upsert per attempt, so spreading attempts across instances buys nothing. A source is an IPv4 address or an IPv6 /64, and an IPv6 source is counted at its /48 as well, at sixteen times the limit: a tunnel broker hands anyone a /48 for nothing, sixty-five thousand /64s, and sign-in, sign-up, password resets, single sign-on and the credential lookup were each that many budgets to it.
Payload integrity keys (issued by /api/v4/integrity, spent by /api/v4/identify)
and spent nonces used to be on this list: a key issued by one instance was
unknown to another, so an identification routed elsewhere was "unverified" and a
sealed payload could not be opened, and a replay landing on another instance was not
seen. Both are in the database now — a key is spent with one DELETE … RETURNING, a
nonce recorded with one INSERT … ON CONFLICT DO NOTHING — so they are exact across
instances and need no sticky routing (pnpm test:shared-state runs each side in a
separate process).
Settings that change what the server will do
| Setting | Effect | Leave it alone unless |
|---|---|---|
ANTIBOT_ALLOW_PRIVATE_OUTBOUND |
Webhooks go only to public https addresses; 1 allows private and loopback ones, 0 forces the production rules even in development |
an integration genuinely lives on an internal network. Setting 1 in production lets a webhook reach it; it does not open the Web Bot Auth key-directory fetch, which is driven by a header any caller can set and is private-capable only on a development server |
ANTIBOT_ENFORCE_BIND_UA (edge proxy) |
false stops refusing a session token presented with a different User-Agent than it was minted for |
something other than the identifying browser presents the cookie — a server-side renderer, a backend-for-frontend, a native app's own HTTP client — or those requests are refused as binding:ua under missing: "deny" |
What the app does not set
- No full Content-Security-Policy. Only
frame-ancestors 'none'is sent, on the dashboard, sign-in/sign-up, onboarding, invitations and waiting-room pages. A full policy has to know every script each page loads and is a separate piece of work. - No
includeSubDomainsor preload in HSTS, noPermissions-Policy. The first commits every subdomain of yours to HTTPS, which only you know is safe; the second is a choice about the sites that embed your pages. X-Frame-Optionsis not on/apior/agent. They are fetched and loaded cross-origin by design.
After deploying, check three things
# 1. The dashboard cannot be framed; the API and the agent are still loadable.
curl -sI https://YOUR-HOST/dashboard | grep -i -E 'x-frame-options|content-security-policy'
curl -sI https://YOUR-HOST/agent/v1/antibot.js | grep -i x-frame-options # (nothing)
# 2. The server log has no "ANTIBOT_CLIENT_IP_HEADER is not set" warning, and
# names the header it is reading from if the platform was detected.
- A client cannot choose its own address. Identify once from your own machine
with forged headers added to the request (
x-forwarded-for: 8.8.8.8,cf-connecting-ip: 8.8.8.8,x-real-ip: 8.8.8.8,true-client-ip: 8.8.8.8) — from a browser's developer tools, or any HTTP client against/api/v4/identifywith a real payload. Open the event in the dashboard. The address must be yours (or its digest, if you identify without keeping addresses), not8.8.8.8. If it shows the forged one, the header you configured is not the one your platform overwrites.
pnpm test:security-headers (the first) and pnpm test:client-ip (the third) check
those in the configuration; they cannot see what your platform actually sends,
which is why this list exists.