Rate limiting as a security control

Capping attempts per identity turns an unlimited brute-force attack into a hopeless one.

The idea

Imagine a vault with a keypad. If a thief can punch in PINs as fast as their fingers move, they will eventually guess yours — there are only so many four-digit codes. But add one rule: after a few wrong tries, the keypad freezes for an hour. Suddenly the same thief would need years to work through the combinations. Nothing about the lock got stronger; you just slowed down the guessing.

Rate limiting is that rule for online systems. By capping how many requests an identity — an IP, an account, an API key — can make per window, you turn brute-force, credential-stuffing, and scraping attacks from "millions of tries a minute" into "a handful, then blocked." It is rarely the whole defense, but it raises the attacker's cost from trivial to impractical while a normal user, who only logs in occasionally, never notices.

Press play to watch an attacker flood the login endpoint.

How it works

Keep a small counter per identity inside a fixed time window (or, equivalently, a token bucket that refills slowly). Each request spends a token; when the bucket is empty, the request is rejected with 429 Too Many Requests and a Retry-After hint. Cross a higher threshold of failures and the account moves to a temporary lockout or a challenge such as CAPTCHA. Hash the key so you are not storing raw IPs or usernames in your limiter store, and grow the penalty with each failure.

import time, hmac, hashlib

LIMIT      = 5       # allowed attempts per window
WINDOW     = 60      # seconds
LOCK_AFTER = 10      # failed attempts before temporary lockout
SECRET     = b"rotate-me"

# store[key] = {"count": n, "fails": n, "reset": epoch, "locked_until": epoch}
store = {}

def limiter_key(account, ip):
    # Hash the identity so the limiter store never holds raw PII,
    # and combine signals so a botnet can't bypass a per-account cap.
    raw = f"{account}|{ip}".encode()
    return hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

def check(account, ip, now=None):
    now = now or time.time()
    key = limiter_key(account, ip)
    rec = store.setdefault(key, {"count": 0, "fails": 0,
                                 "reset": now + WINDOW, "locked_until": 0})

    if now < rec["locked_until"]:
        return 429, int(rec["locked_until"] - now)   # still locked out

    if now >= rec["reset"]:                           # window rolled over
        rec["count"], rec["reset"] = 0, now + WINDOW

    # NOTE: in production this read-modify-write must be ATOMIC
    # (e.g. Redis INCR + EXPIRE, or a Lua script) so concurrent
    # requests can't race past the cap.
    rec["count"] += 1
    if rec["count"] > LIMIT:
        return 429, int(rec["reset"] - now)           # over the cap

    return 200, 0                                     # allowed through

def login(account, password, ip):
    status, retry = check(account, ip)
    if status == 429:
        # Same generic message whether or not the account exists,
        # so the response never reveals which usernames are valid.
        return {"status": 429, "Retry-After": retry,
                "body": "Too many attempts. Try again later."}

    rec = store[limiter_key(account, ip)]
    if not verify(account, password):
        rec["fails"] += 1
        if rec["fails"] >= LOCK_AFTER:                 # exponential-ish backoff
            penalty = WINDOW * (2 ** (rec["fails"] - LOCK_AFTER))
            rec["locked_until"] = time.time() + min(penalty, 3600)
        return {"status": 401, "body": "Invalid credentials."}

    rec["fails"] = 0                                   # reset streak on success
    return {"status": 200, "body": "Welcome back."}

Trade-offs

Different keys stop different attacks and carry different false-positive risks. Strong systems layer several together.

ControlStopsFalse-positive risk
Per-IP limitA single noisy host scraping or hammering one addressHigh — shared NAT, office, school, or carrier-grade NAT puts many real users behind one IP
Per-account limitBrute-force against one specific account, even from a botnet of many IPsLow for the owner, but lets an attacker lock victims out (denial of service) if not tuned
Per-API-key limitOne client or integration abusing or exceeding its quotaLow — the key maps to a known tenant; collateral is contained to that tenant
Global limitVolumetric floods that threaten overall capacity and cost (denial of wallet)High during legitimate traffic spikes; a blunt last resort
Rate limit vs CAPTCHALimit caps volume; CAPTCHA forces a human after thresholdsCAPTCHA adds friction and accessibility cost; bots increasingly solve it
Rate limit vs lockoutLimit slows; lockout fully halts an identity after N failuresLockout is the easiest to weaponize for denial of service against a target account

Watch out for

Worked example

Say an attacker has bought a list of leaked email-and-password pairs and runs a credential-stuffing attack: they replay each pair against your login, hoping a customer reused a password. Against the same account, your limiter allows 5 attempts per 60-second window. The first few guesses pass the gate and hit the password check (and fail). Once the bucket empties, the next request spends a token that is not there, so the gate returns 429 with Retry-After: 60 — and every flood request after it hits the same wall.

Because the cap is keyed to the account, the attacker cannot dodge it by rotating through their botnet's IP addresses; all those requests still land on the one account's counter. After enough failures the account drops into a short lockout that doubles on each further failure. Meanwhile a real customer, logging in once with the right password, sits comfortably under the cap and never sees a single 429. The flood is reduced to a trickle; the legitimate request flows through. Watch the visual above — the terracotta attempts pile up against the gate while the green legitimate request slips past.

Check yourself

An attacker controls a 50,000-machine botnet and is brute-forcing one specific user account, one guess per IP. You only have a per-IP rate limit. Does it stop the attack?

Your login returns 429 after a few tries for real accounts, but for usernames that do not exist it returns 401 instantly with no limiting. What have you accidentally created?