Abuse and rate limiting

Make a million attempts cost a million times more than one, without punishing real people.

12of 16
≈ 12Min read
5Parts

What you are slowing down

Limiting only by IP misses the attacker with a thousand addresses and punishes the office behind one. Limiting only by account misses the spread attack. Real systems do both, with different numbers.

AbuseLooks likeLimit by
Credential stuffingone attempt each on ten thousand accountsIP, and globally per endpoint
Brute forceten thousand attempts on one accountthe account, not the IP
Enumerationprobing which emails or ids existthe endpoint, plus identical answers
Scrapingthe whole catalogue, politely, all nightIP or key, with a generous ceiling
Expensive endpointsreport generation, search, uploadsa small concurrent limit

Three algorithms

A token bucket matches how people actually use software: a burst of five requests when a page loads is fine; a sustained five a second is not. Capacity is the burst you tolerate, refill rate is the long-run limit.

in order of how well they behave
// fixed window — simple, and lets through 2× at the boundary
count[minute]++ ; allow = count[minute] <= limit

// sliding window — smooths the boundary, costs a little more state
allow = attemptsInLast(60_000) < limit

// token bucket — allows a burst, then a steady rate. the usual choice
tokens = min(capacity, tokens + elapsed * refillPerSecond)
allow = tokens >= 1 && (tokens -= 1, true)

Answer properly

A 429 with Retry-After lets a well-behaved client back off instead of hammering. Returning 403, or a 200 with an error in the body, teaches every client to retry immediately — and makes your own dashboards useless.

a limit the client can cooperate with
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30

Locking an account, without giving anyone a lockout weapon

A hard lock after five failures is a denial-of-service tool: anybody who knows an email can lock that user out. Exponential backoff per account is better — 1 second, then 2, 4, 8, up to a minute — because it stops a brute force dead while a real person who mistyped their password waits a moment.

Reset the counter on a successful login, notify the account owner after a burst, and require a second factor rather than a lock when the pattern looks like an attack.

Do not leak through the limiter

If a rate-limited login says "too many attempts for this account" and an unknown email says "no such user", the limiter has just become the enumeration oracle the password lesson closed. Same message, same shape, whatever happened.

Where the counter lives

In one process, a Map is fine. Behind three servers, an in-memory counter means three times the limit — the state has to be shared, which in practice means Redis with an atomic increment, or the edge/CDN doing it for you before the request arrives.

And limit at the cheapest layer that can see enough: a CDN rule costs nothing, a middleware costs a request, a database-backed check costs a query. Put the crude ceiling at the edge and the precise one in the application.

Practice