Start here: one limiter, a million callers#
TL;DRthe 30-second version
- A per-key rate limiter caps one caller. Distributed abuse spreads across thousands of IPs and mimics real users, so each individual caller stays under the limit while the aggregate attack succeeds.
- The named threats are concrete: credential stuffing (testing stolen passwords), scraping (bulk-copying your content), card testing (checking stolen card numbers), and spam. OWASP catalogs them as automated threats (OAT-008, OAT-011, OAT-001).
- Defense is layered, not single: identity and reputation (who is this caller), friction and proof-of-work (make each request cost something), behavioral scoring (does this act human), and a web application firewall at the edge (block known-bad before it reaches you).
- None of these is a solution. The goal is to raise the attacker's cost above their payoff, while keeping friction low enough that real users don't get blocked. That false-positive cost is the whole game.
Rate limiting (covered in full at /ratelimit) answers a simple question: is this one caller sending too much? It keeps a small counter per key and rejects requests over budget. That is exactly the right tool when the abuse has a single source, like a buggy client stuck in a retry loop.
The trouble starts when the abuse is deliberate and distributed. Say an attacker has a list of ten million stolen email-and-password pairs from some other site's breach, and wants to find which ones also work on your login page. This is credential stuffing. They do not send ten million requests from one IP address. They rent a botnet or a pool of residential proxies and send each guess from a different address, at a human-looking pace, with a normal-looking browser header.
Against that, a per-IP rate limit is helpless. Each individual IP sends only a handful of requests, well under any limit you would set for a real user. The limiter sees a million polite callers, not one attacker. Lower the limit far enough to catch them and you start rejecting real people on shared office networks and mobile carriers, who legitimately share an IP.
The fix is layers, not a wall#
No single check separates a bot from a human cleanly. So you stack cheap, imperfect filters in front of expensive ones. Each layer removes some of the abuse and passes the rest down. The blocked traffic never reaches your application logic, and the traffic that does arrive has already paid a cost to get there.
Layer one is the rate limiter you already know. The four layers below exist because a distributed attacker slips past it. The first of those is identity and reputation: before you count a caller's requests, ask who the caller even is.
Reputation attaches a history to the caller so you can judge them before they act. The simplest signal is the IP address: threat-intelligence feeds publish lists of addresses known to belong to botnets, open proxies, or hosting providers (real users rarely browse from a datacenter IP). Next is the network the address sits in, identified by its ASN (Autonomous System Number, the id of the network operator) and its geography. Traffic claiming to be a suburban home user but originating from a cloud provider's ASN is suspicious on its face.
The strongest reputation signal is a stable identity. A logged-in account (see /auth) is worth far more than an anonymous IP, because you can rate-limit and reputation-score the account itself, and an attacker has to steal or create accounts to get one. Device fingerprinting reaches for a similar anchor without a login: it hashes together dozens of small browser properties (screen size, fonts, timezone, rendering quirks) into one id that tends to stay the same across IP changes, so a single attacker rotating through a thousand IPs can still look like one device.
Go deeperUnder the hood: fingerprinting the TLS handshake (JA3 / JA4)
Browser-level fingerprinting can be defeated by a headless browser that fakes its properties. So defenders reach lower, into the TLS handshake itself — the encrypted-connection setup that happens before any HTTP request. When a client opens a TLS connection it sends a ClientHello listing the cipher suites, extensions, and elliptic curves it supports, in a specific order.
JA3 (and its successor JA4, from FoxIO) hashes that list into a short fingerprint. The point is that a real Chrome build produces one characteristic fingerprint, while a Python requests script or a Go HTTP client produces a completely different one, because their TLS libraries offer different suites in a different order. An attacker can spoof the browser's User-Agent header trivially, but matching its exact TLS fingerprint means reimplementing the browser's networking stack. Cloudflare exposes the JA3 and JA4 hash as a signal its bot-detection models weigh, precisely because it is hard to forge and reveals the tool behind a disguised request.
Friction and proof-of-work: make each request cost something#
Reputation catches callers with a bad history. But a fresh residential IP has no history, good or bad. For those, you make the caller do work to proceed, so that abuse at volume becomes expensive even when each individual request looks clean.
The oldest tool is the CAPTCHA: a puzzle that is easy for a human and hard for a bot, like reading distorted text or picking out traffic lights. It works, but it taxes the wrong people. Every real user pays with annoyance and time, accessibility suffers, and modern solving services break the puzzles cheaply anyway. So the trend is toward invisible challenges: the page silently runs a check in the background (a small script probing for signs of automation, mouse movement, browser sanity) and only shows a puzzle if the check looks suspicious. Most real users never see a thing.
Proof-of-work takes a different angle: instead of proving you are human, prove you spent CPU. The server hands the client a puzzle that takes measurable compute to solve but is trivial to verify. One legitimate request pays a fraction of a second, unnoticed. An attacker sending a million requests pays a million times that, which quietly wrecks the economics of mass abuse. The idea is Hashcash, invented in 1997 to fight email spam, and it is enjoying a second life in tools like mCaptcha.
Go deeperUnder the hood: how a proof-of-work challenge is checked
A Hashcash-style challenge asks the client to find a number (a nonce) such that the hash of the server's challenge string plus that nonce starts with a required number of leading zero bits. Because a cryptographic hash is unpredictable, the only way to find such a nonce is to try values one after another until one works. Requiring N leading zero bits means the client expects to try about 2^N values, so difficulty scales exponentially — each extra required bit doubles the attacker's work.
Verification is the cheap half. The server receives the winning nonce, computes one hash, and checks the leading zeros. Finding the nonce might take the client millions of hash attempts; checking it takes the server exactly one. That asymmetry (expensive to produce, trivial to verify) is what makes proof-of-work usable at scale. Systems like mCaptcha raise the required difficulty automatically when traffic spikes, so a sustained attack gets exponentially more expensive while normal users keep paying almost nothing. The honest caveat: an attacker with a GPU solves these far faster than a browser can, so proof-of-work raises the cost floor rather than closing the door.
Behavioral detection: does this act human?#
Reputation asks who the caller is; a challenge asks them to prove something. Behavioral detection asks a third question: does this traffic pattern look like a person? It watches what callers do over time and scores how human it seems.
The workhorse is the velocity check: count actions per identity across a window, but count the actions that matter to the attack rather than raw requests. Ten login failures a minute from one account is a person who forgot their password. Ten login failures a minute across ten thousand accounts, all from one device fingerprint, is credential stuffing, even though no single account is over its limit. The signal lives in the aggregate that a per-key limiter cannot see.
On top of velocity sits anomaly scoring. You build a picture of normal traffic — the usual mix of geographies, the usual time between a page load and a form submit, the usual ratio of browse to buy — and flag the requests that deviate. A bot filling a login form in 40 milliseconds, or a signup wave from one region at 3am, stands out against that baseline. At scale this is a machine-learning problem: Cloudflare reports that a model trained on the billions of requests it sees daily produces the majority of its bot detections, scoring each request on how human it looks rather than matching a fixed rule.
What each layer costs to run#
Every layer buys protection with some mix of latency, compute, state, and false positives. Picking a defense means knowing which resource it spends, because the wrong choice can cost you more than the abuse would.
| Layer | Main cost | Roughly how much |
|---|---|---|
| Edge WAF / signatures | Latency on every request | Sub-millisecond rule match at the edge |
| IP reputation lookup | A feed to maintain + a lookup | O(1) set membership per request |
| Device fingerprinting | Client script + a hash to store | One id computed and stored per visitor |
| Proof-of-work challenge | Client CPU + one server hash to verify | Client does ~2^N hashes; server does 1 |
| Behavioral / ML scoring | State per identity + model inference | Aggregates over a rolling window, scored live |
The cost that dominates every design is the false-positive rate: the fraction of real users a defense wrongly blocks. It sounds small until you multiply. A defense that is 99.9% accurate wrongly challenges one user in a thousand. On a site with ten million daily logins, that is ten thousand real people hitting a wall every day. Each of them is a support ticket, an abandoned cart, or a lost customer. This is why the strongest defenses are applied selectively, on suspicion, rather than to everyone.
PredictYou add a proof-of-work challenge to every login to stop credential stuffing. Attacks drop, but so do successful logins from real users. What did the blanket challenge cost you, and what is the fix?
Hint: Who pays the CPU cost when the challenge runs on everyone?
A challenge on every login taxes every real user too. Some are on old phones or weak laptops where the proof-of-work takes seconds, some have battery-saving or script-blocking settings, and some just give up when the page stalls. You have converted an abuse problem into a conversion problem, and lost real logins to friction. The fix is to challenge selectively: let reputation and behavioral scoring flag the suspicious traffic first, and only make those callers do the work. Real users on clean identities sail through untouched; the challenge falls on the callers who already look like bots. Blanket friction is the classic false-positive trap.
The real trade: friction against security#
Every knob on this page moves the same three quantities in tension, and you cannot max out all three at once. Turn one up and at least one other suffers.
- Security — how much abuse you stop. Harder challenges and stricter rules catch more bots.
- User friction — how much you inconvenience real people. Every challenge, delay, and CAPTCHA is a tax on the legitimate majority.
- False positives — how many real users you wrongly block. Tighten the rules to catch more bots and you inevitably snare more humans too.
Push security to the maximum with hard challenges on every request, and friction and false positives climb until real users leave. Remove all friction for a frictionless experience, and abuse walks in. The job is not to pick a corner but to hold a sensible middle, and to spend your strongest, most annoying defenses only where the risk is highest, so most users feel nothing.
Go deeperUnder the hood: the trilemma is a CAP-style choice
It helps to think of security, low friction, and low false positives as three corners you are forced to trade between, the way CAP forces a choice between consistency and availability under a partition. A perfectly secure system that never wrongly blocks anyone and never asks a real user to do anything cannot exist, because the only way to be certain a caller is human is to test them, and every test is friction that some humans fail.
So real designs pick a point and make it adaptive. They run low friction by default for callers who look clean on reputation and behavior, and dial up security (challenge them, or block them) only for the traffic that already looks risky. The result is a system that feels frictionless to the honest majority and expensive to the abusive minority, which is the closest thing to having all three that the trilemma allows.
The four defenses side by side#
Each layer catches a different kind of attacker and misses a different kind. Read the table as which layer to reach for given how the abuse is disguised.
| Defense | Catches | Beaten by | Best against |
|---|---|---|---|
| IP / ASN reputation | Callers from known-bad networks | Fresh residential proxies | Botnets, datacenter traffic |
| Device fingerprinting | One device behind many IPs | Anti-detect browsers that randomize | IP-rotating attackers |
| Proof-of-work | High-volume automation (by cost) | GPUs, low-volume attacks | Mass scraping, spam floods |
| Behavioral / ML scoring | Non-human patterns and velocity | Slow, human-paced bots | Credential stuffing, carding |
Notice that each row's weakness is another row's strength. Reputation misses fresh proxies, but fingerprinting and behavior catch the device and pattern behind them. Proof-of-work misses a patient low-volume attacker, but behavioral scoring notices their non-human timing. Layering them is not redundancy; it is coverage, because the gap in one defense is filled by the next.
In the wild
- Cloudflare Bot Management runs four detection engines together: a heuristics engine matching known bad fingerprints, a machine-learning engine (trained on billions of daily requests) that produces most of the detections, an anomaly-detection engine, and JavaScript challenges for headless browsers. Each request gets a bot score from 1 (definitely automated) to 99 (likely human).
- A web application firewall (WAF) sits at the edge and blocks requests matching known-bad signatures before they reach the app. The OWASP ModSecurity Core Rule Set is the open-source ruleset behind many WAFs; managed bot rules from cloud providers layer machine-scored bot detection on top.
- Arkose Labs and similar vendors lean on proof-of-work and adaptive challenges, explicitly framing the defense as raising the attacker's cost rather than perfectly telling humans from bots.
- mCaptcha is an open-source proof-of-work CAPTCHA that raises puzzle difficulty automatically during traffic spikes, so a sustained attack pays exponentially more while normal users pay almost nothing.
- Credential stuffing specifically targets password stores, which is why hashing passwords slowly with a salt (see /hashing-passwords/sim) is the paired defense — it makes the stolen list that feeds the attack far more expensive to crack in the first place.
Pitfalls & gotchas
Why isn't a low rate limit enough to stop credential stuffing?
Because the attack is distributed. Each of the thousands of IPs sends only a few requests, all under any limit a real user would need. Set the limit low enough to catch them and you block real users who share IPs on office networks and mobile carriers. The abuse hides in the aggregate across many callers, which a per-key limiter cannot see.
Aren't CAPTCHAs a solved defense?
No. CAPTCHAs tax every real user with friction and accessibility problems, and solving services break them cheaply for attackers. The modern approach is invisible challenges that run silently and only surface a puzzle on suspicion, plus proof-of-work that charges CPU instead of human effort. A CAPTCHA on every request is usually the wrong default.
Can proof-of-work stop a determined attacker outright?
No, and it does not try to. It raises the cost of each request so that abuse at volume becomes uneconomic, but an attacker with GPUs solves the puzzles far faster than a browser. Think of it as pricing, not blocking: it makes cheap mass abuse expensive, which is often enough to make the attacker leave.
What's the biggest risk when you tighten these defenses?
False positives — blocking real users. A defense that is 99.9% accurate still wrongly challenges one user in a thousand, which at scale is thousands of lost customers a day. This is why the strongest defenses are applied selectively, only to traffic that already looks suspicious, rather than to everyone.
QuizAn attacker scrapes your product catalog using a pool of residential proxies, a headless browser that spoofs a real User-Agent, and human-paced request timing. Which single defense is most likely to catch them?
- A stricter per-IP rate limit
- An IP reputation blocklist
- TLS fingerprinting plus behavioral scoring across the session
- A CAPTCHA on the homepage
Show answer
TLS fingerprinting plus behavioral scoring across the session — The attacker has specifically defeated the first two: residential proxies give fresh IPs that dodge both a per-IP limit and a reputation blocklist, and human-paced timing beats a naive velocity check. A homepage CAPTCHA does nothing for a scraper hitting product pages directly. What they have not defeated is the TLS fingerprint of their headless browser's networking stack, which differs from a real browser even when the User-Agent is spoofed, combined with behavioral scoring that spots the machine-like session as a whole (perfect navigation, no idle time, catalog-order page visits). No single layer is a silver bullet, but the fingerprint-plus-behavior combination is the one designed for exactly this disguise.
In an interview
When asked to design abuse prevention, the mistake is to answer 'add a rate limiter' and stop. Lead instead with why a plain limiter fails against distributed abuse, then build the layers on top. That framing shows you understand the actual threat model.
- Start with the gap: a per-key rate limiter caps one caller, but credential stuffing, scraping, and card testing spread across thousands of IPs and mimic real users, so each caller stays under the limit.
- Name the layers you add: identity and reputation (IP / ASN / device fingerprint), friction and proof-of-work (challenge selectively, charge CPU), behavioral scoring (velocity and anomaly detection on the aggregate), and a WAF at the edge.
- State the governing trade: security against user friction against false positives. You cannot max all three, so apply strong defenses selectively, on suspicion, not to everyone.
- Land the honest framing: this is economics, not a solvable problem. The goal is to raise the attacker's cost above their payoff, not to build an impenetrable wall.
- Bring up the paired defense: credential stuffing preys on password reuse and stolen stores, so slow, salted password hashing (/hashing-passwords/sim) and pushing users toward multi-factor auth attack the problem at the source.
PredictThe interviewer says: 'Our signup endpoint is being flooded with fake accounts from thousands of IPs. Rate limiting per IP didn't help. What would you do?' What's the strong answer?
Hint: Distributed source, fresh IPs — which layers still bite?
First name why the rate limit failed: the fake signups come from thousands of distinct IPs, each sending only a few requests, so a per-IP cap either misses them or blocks real users on shared networks. Then layer up. Add reputation to score the network (datacenter and known-proxy ASNs are almost never real signups) and device fingerprinting to collapse the thousand IPs back into the handful of devices actually behind them. Add a challenge on suspicion — an invisible check that escalates to proof-of-work or a CAPTCHA only for callers who already look risky — so real users are untouched while the bot farm pays CPU per account. Add behavioral scoring on the aggregate: a wave of signups from one region, filling the form in 40 milliseconds, with sequential email addresses, is obvious in aggregate even when each request looks fine alone. Close on the economics: the aim is to make each fake account cost more to create than it is worth, and to keep friction off the real users. Naming the layers and the friction-versus-false-positive trade, rather than just 'a better rate limit,' is what makes the answer strong.
References
- OWASP — Automated Threats to Web Applications — The taxonomy: credential stuffing (OAT-008), scraping (OAT-011), carding (OAT-001), and 18 more.
- OWASP — Credential Stuffing Prevention Cheat Sheet — Practical layered defenses: MFA, device fingerprinting, and challenges.
- Cloudflare — Bot detection engines — Heuristics, machine learning, anomaly detection, and JavaScript challenges, and how the bot score is formed.
- Cloudflare — JA4 fingerprints and inter-request signals — Fingerprinting the TLS handshake to catch disguised automation.
- Hashcash — a denial-of-service counter-measure (Adam Back) — The original proof-of-work idea: expensive to produce, trivial to verify.
- mCaptcha — proof-of-work CAPTCHA (Communications of the ACM) — Variable-difficulty proof-of-work that scales with attack intensity.