Design a bot detection system
Design the system that tells humans from automation on a busy consumer site: TLS and HTTP/2 fingerprints like JA4, device and behavioural signals, a cheap score at the edge and a heavier one offline, a challenge ladder instead of a block button, and the feedback loop that keeps it working once attackers adapt.
01Understand the problem and size it
- Name the signal families: network (IP, ASN), protocol (JA4, HTTP/2 fingerprint), device (JS-collected traits) and behaviour
- Split scoring into a sub-millisecond edge score and an asynchronous aggregate score that feeds it
- Design a challenge ladder (silent, proof-of-work, interactive, block) instead of a binary allow/deny
- Handle privacy, evasion (curl-impersonate, residential proxies, CAPTCHA farms) and label feedback
What they probe: which signals survive an attacker who controls every header, where the score is computed and how fast, what happens on a false positive, how the system learns once attackers adapt. Where candidates slip: user-agent and IP rules only, a CAPTCHA on every suspicious request, returning the reason for a block to the client, no plan for labels.
A bot here is any client driven by software rather than a person: scrapers, credential stuffers, scalpers buying limited stock, fake sign-up farms. Some bots are wanted (search crawlers, uptime monitors, partner APIs), so the system must also verify good bots, usually by reverse-then-forward DNS of the crawler IP or a published IP list.
Functional requirements
- Score every HTTP request to protected routes (login, sign-up, checkout, search) for likelihood of automation.
- Act on the score per route: allow, silently challenge, interactively challenge, rate limit, or block.
- Let verified good bots through and let product teams set per-route policy without a deploy.
- Feed outcomes (challenge results, fraud labels, support reports) back into rules and models.
Non-functional requirements
- 300k requests/s peak across 200 edge points of presence (PoPs).
- Edge decision adds < 1 ms p99; the site must not slow down for humans.
- False positive rate on humans < 0.1 % on checkout, because a blocked buyer is lost revenue.
- Collect the minimum personal data, with documented retention.
Back of the envelope
peak requests 300,000 / s across 200 PoPs = 1,500 / s per PoP on average
edge scoring CPU 0.5 ms per request
-> 300,000 x 0.5 ms = 150 core-seconds per second = 150 cores fleet-wide
request metadata to async 400 B per request (JA4, H2 fp, IP, ASN, route, status, cookie id)
-> 300,000 x 400 B = 120 MB/s ≈ 10.4 TB/day raw
aggregate state 50 M distinct IPs/day x 100 B ≈ 5 GB (fits in memory per region)
model push to edge every 5 min, a few MB of lists + weights02High-level design and the signals
- ACTOR
- EDGE
- SERVICE
- DATA
- THREAT
- Live flow
Signal families
| Family | Examples | Cost to fake | Notes |
|---|---|---|---|
| Network | IP reputation, ASN type (hosting vs residential vs mobile), Tor exit, geo | Low: residential proxies sell clean IPs by the GB | Good for priors, weak alone; mobile carrier NAT puts thousands of humans behind one IP |
| TLS fingerprint | JA4 of the ClientHello: TLS version, cipher and extension counts, ALPN, hashes of sorted ciphers and extensions | Medium: needs a TLS stack that mimics a browser (curl-impersonate, utls) | Cannot be set by a header; mismatch with the claimed User-Agent is a strong signal |
| HTTP/2 fingerprint | SETTINGS values and order, WINDOW_UPDATE size, pseudo-header order | Medium | Python and Go HTTP/2 clients differ visibly from Chrome |
| Device (JS) | navigator.webdriver, canvas and WebGL traits, fonts, screen, timezone vs IP geo | Medium to high | Only on pages that run your script; headless stealth plugins patch the obvious ones |
| Behaviour | pointer paths, keystroke timing, time-to-submit, navigation order | High to fake at scale | Needs a session of activity; privacy-sensitive |
| History | per-fingerprint and per-IP rates, challenge pass rate, account outcomes | High | Only the async tier can compute it |
JA4 in one paragraph
JA3 (2017) hashed the raw ClientHello fields with MD5. When Chrome began randomising TLS extension order in early 2023, every Chrome connection got a different JA3. JA4 (FoxIO, 2023) fixes that by sorting ciphers and extensions before hashing and by putting readable fields up front. A JA4 has three parts separated by underscores: a readable prefix, a truncated SHA-256 of the sorted cipher list, and a truncated SHA-256 of the sorted extensions plus signature algorithms.
JA4 = t13d1516h2_8daaf6152771_e5627efa2ab1
| | | | | | | |
| | | | | | | +-- sha256(sorted extensions + sig algs)[:12]
| | | | | | +--------------- sha256(sorted cipher suites)[:12]
| | | | | +------------------ ALPN first and last char: "h2"
| | | | +-------------------- 16 extensions
| | | +---------------------- 15 cipher suites
| | +------------------------ d = SNI is a domain (i = IP, no SNI)
| +-------------------------- TLS 1.3
+---------------------------- t = TCP (q = QUIC)
request: User-Agent: Chrome/131 on Windows
JA4 seen: t13d1312h2_... (a Go net/http stack, not Chrome)
verdict: claimed browser and TLS stack disagree -> strong automation signalTwo scoring tiers
- Edge (synchronous): features available from this request alone plus pushed state: JA4 and HTTP/2 fingerprint, UA consistency, IP and ASN reputation, cookie validity, a small gradient-boosted model or weighted rules. Budget 0.5 ms CPU, no network calls.
- Async (regional stream): aggregates over windows: requests per JA4 per ASN per minute, login failures per fingerprint, distinct accounts per device cookie, challenge pass rates. Produces lists (fingerprint plus ASN pairs to challenge, IP ranges to rate limit) and refreshed weights pushed to the edge every few minutes.
- Session cookie: once a client passes a challenge, the edge issues a signed, short-lived cookie (HMAC over cookie ID, JA4, IP prefix, expiry) so it is not challenged on every request. Binding to JA4 stops a solved cookie being replayed from a different stack.
03Deep dives: challenges, privacy, evasion
The challenge ladder
A block teaches the attacker exactly what tripped. A ladder costs bots more than humans and gives you labels at each rung.
| Score band | Action | Human cost | Bot cost |
|---|---|---|---|
| 0 to 30 | Allow | None | None |
| 30 to 60 | Silent JS check plus small proof-of-work (about 100 ms on a phone) | Invisible | Must run a real JS engine; PoW adds CPU per request at scale |
| 60 to 85 | Interactive challenge or Private Access Token | A few seconds | CAPTCHA farm fees, about $1 to $3 per 1,000 solves |
| 85 to 100 | Block, tarpit, or serve plausible decoy data | High if wrong | Wasted requests, poisoned scraped data |
Thresholds are per route: search can tolerate more automation than checkout. Private Access Tokens (the Privacy Pass protocol, RFC 9576 to 9578) let a device attest "a real device solved a check" through an issuer without revealing identity, which removes the CAPTCHA for most Apple users.
Edge scoring logic
type Action = "allow" | "silent" | "interactive" | "block";
interface EdgeFeatures {
ja4: string;
uaFamily: "chrome" | "firefox" | "safari" | "other";
asnType: "residential" | "mobile" | "hosting" | "unknown";
ipRep: number; // 0 (clean) .. 1 (known bad), from pushed list
hasValidClearance: boolean;
}
const JA4_RE = /^[tqd][0-9a-z]{2}[di][0-9]{4}[0-9a-z]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/;
export function score(f: EdgeFeatures, knownBrowserJa4: Map<string, string>, listed: Set<string>): number {
if (!JA4_RE.test(f.ja4)) return 90; // malformed or missing: treat as hostile
if (f.hasValidClearance) return 10;
let s = 20 * f.ipRep;
if (f.asnType === "hosting") s += 25;
const family = knownBrowserJa4.get(f.ja4); // ja4 -> browser family it belongs to
if (family === undefined) s += 15; // unseen stack: new browser build or a tool
else if (f.uaFamily !== "other" && family !== f.uaFamily) s += 40; // claims a browser, TLS says otherwise
if (listed.has(f.ja4 + "|" + f.asnType)) s += 30; // async tier flagged this pair
return Math.min(100, Math.max(0, Math.round(s)));
}
export function decide(s: number, route: { silent: number; interactive: number; block: number }): Action {
if (s >= route.block) return "block";
if (s >= route.interactive) return "interactive";
if (s >= route.silent) return "silent";
return "allow";
}Privacy
- Behavioural and device signals are personal data under GDPR when tied to a user. Collect them for security under legitimate interest, document it, and keep raw pointer traces only as derived features, not recordings.
- Hash IPs with a rotating keyed hash in the analytics tier; keep raw IPs at the edge for minutes, not months.
- Retain request metadata 30 days for training and investigations, aggregates longer.
Evasion and how the design answers it
| Evasion | What it beats | Answer |
|---|---|---|
| curl-impersonate, utls | JA4 and HTTP/2 mismatch | Device JS checks and behaviour; impersonation libraries lag new browser releases, so stale fingerprints become a signal |
| Headless Chrome with stealth patches | Simple navigator.webdriver checks | Consistency checks across dozens of traits; PoW cost at volume; behaviour on forms |
| Residential proxy pools | IP reputation and per-IP limits | Aggregate by fingerprint and device cookie rather than IP; ASN priors |
| CAPTCHA farms | Interactive challenges | Price the attack: rate limits per account and per payment instrument downstream; do not rely on the challenge alone |
Returning "blocked: TLS fingerprint mismatch" hands the attacker a free oracle. Return a generic page with a reference ID; the reason lives in your logs. For the same reason, enforce some list updates with a random delay so attackers cannot bisect which change caught them.
04Failure modes, feedback loops and trade-offs
Failure modes
| Failure | Behaviour | Why |
|---|---|---|
| Async tier down | Edge keeps last pushed lists and weights; alert if older than 30 min | Edge must never block on the analytics path |
| Bad model push | Canary to 1 % of PoPs, auto-rollback if challenge rate jumps > 2x | A bad push is an outage for real buyers |
| Challenge provider down | Fall back to PoW plus tighter rate limits | Never fail open to unlimited login attempts |
| Mobile app traffic | No JS; use app attestation (Play Integrity, App Attest) instead | The ladder needs a native equivalent |
Labels and feedback loops
- Weak labels arrive fast: challenge pass or fail, PoW completed, cookie reuse patterns.
- Strong labels arrive slowly: accounts later confirmed fraudulent, chargebacks on scalped orders, users reporting takeover.
- Selection bias: blocked traffic never shows its true outcome. Keep a small random holdout (for example 1 % of mid-band traffic is challenged instead of blocked) so you can measure what you would have caught.
- Adversarial drift: watch the score distribution per route daily; a sudden shift towards the middle bands usually means a new evasion.
Verify crawlers by reverse DNS of the source IP (it must resolve to the crawler's documented domain) and then forward DNS back to the same IP. A User-Agent saying Googlebot proves nothing.
Trade-offs to state
| Choice | Gain | Cost |
|---|---|---|
| Edge score vs central scoring service | No added latency, survives regional outage | Only per-request features; aggregates arrive minutes late |
| Challenge vs block | Labels, fewer lost humans | Attackers can pay farms; needs a challenge vendor |
| Binding clearance to JA4 | Cookie replay across stacks fails | Browser updates change JA4 and force re-challenge |
| Decoy data vs block | Poisons scrapers, hides detection | Must never reach real users or search engines |
05Final design, follow-ups and self-test
"I'd score at the edge with what the handshake gives me for free: JA4, the HTTP/2 fingerprint and whether they agree with the User-Agent, plus pushed IP and ASN reputation. That's half a millisecond and no network calls. A regional stream aggregates by fingerprint and device cookie, not just IP, because residential proxies make IPs cheap, and pushes lists back every few minutes. Actions are a ladder, silent proof-of-work, then an interactive challenge, then block, tuned per route, and every rung gives me labels. I keep a small holdout so I can measure what I'm missing, and I never tell the client why it was blocked."
Q1How do you protect a mobile app API that cannot run JavaScript challenges?+
Use platform attestation (Play Integrity on Android, App Attest on iOS) to prove a genuine app on a genuine device, bind the result to a short-lived token, and keep the same aggregate scoring by device and account.
Q2A big customer complains their legitimate integration is being challenged. What do you do?+
Give them an authenticated API path (API keys or mTLS) that bypasses the browser ladder but has its own rate limits. Do not allow-list by IP or User-Agent, which attackers copy.
Q3How would you detect a new evasion you have never seen?+
Unsupervised signals: sudden new JA4 values with high request share, clusters of sessions with identical timing, conversion-rate collapses per route. Then label a sample by hand and add features.
Q4Why not just rate limit by IP?+
Residential proxy pools spread an attack over tens of thousands of IPs at a few requests each, while mobile carrier NAT puts thousands of real users behind one IP. Per-IP limits block humans and miss bots.
A request claims to be Chrome 131 on Windows but its JA4 matches the Go standard library TLS client. What is the best interpretation?
Related lessons
- Design an account takeover detection systemHLD · Design problems: fraud & abuseFree
- Design a DDoS protection edgeHLD · Design problems: Infrastructure & dataPro
- Design an API authorisation gatewayHLD · Design problems: Access & identityFree
- DDoS and WAF fundamentalsSecEng · Network & Infrastructure SecurityFree