Rate limiting and brute-force protection

A login form with no limit on failed attempts isn't broken in any obvious way — every single request behaves correctly — but that correctness is exactly what lets an attacker try millions of password guesses automatically, at whatever speed their own hardware allows.

Intermediate

4 min read

The vulnerability: correct behavior, repeated without limit

@app.route("/login", methods=["POST"])
def login(request):
    user = find_user(request.form["email"])
    if user and check_password(user, request.form["password"]):
        return create_session(user)
    return "Invalid credentials", 401
    # NOTHING stops this endpoint from being called 10,000 times per second

This login endpoint has no actual bug — it correctly checks a password and correctly rejects a wrong one, every single time. The vulnerability isn't in any individual request; it's in the absence of any limit on how many times the endpoint can be called — an attacker can script a program to try thousands or millions of password guesses automatically, and this endpoint will faithfully check every single one, exactly as designed, with nothing to slow that process down at all.

Brute force: trying every possibility until one works

An attacker with a list of common passwords (or a full character-by-
character brute force) can attempt LOGIN_ATTEMPTS_PER_SECOND × SECONDS
guesses against a single account — for a weak or common password, this
can succeed in minutes without any limit on attempt rate

Brute force is exactly what it sounds like: trying every possible value (or a curated list of likely ones — a "dictionary attack" using common passwords) until one succeeds. This isn't a sophisticated technique requiring deep expertise — it's a simple script and enough time — which is precisely why the actual defense isn't about making passwords theoretically stronger, it's about making the attempt rate itself bounded, so brute force becomes impractically slow rather than impossible in principle.

The fix: rate limiting, tracked per meaningful key

from collections import defaultdict
import time
 
attempts = defaultdict(list)  # tracks attempt TIMESTAMPS per key
 
def is_rate_limited(key, max_attempts=5, window_seconds=60):
    now = time.time()
    attempts[key] = [t for t in attempts[key] if now - t < window_seconds]  # drop OLD attempts
    if len(attempts[key]) >= max_attempts:
        return True
    attempts[key].append(now)
    return False
 
@app.route("/login", methods=["POST"])
def login(request):
    email = request.form["email"]
    if is_rate_limited(email):
        return "Too many attempts, try again later", 429
    # ... proceed with the actual login check

Rate limiting tracks how many attempts have happened within a recent time window, keyed by something meaningful (per email address, per IP, or both), and rejects requests once a threshold is exceeded — a real HTTP status code (429 Too Many Requests) exists specifically for this. This doesn't make brute force impossible in a strict mathematical sense, but it makes it impractically slow — five attempts per minute instead of thousands per second turns a minutes-long attack into one that would take years.

Why rate limiting by IP address alone is a real, incomplete defense

An attacker distributing the attack across MANY different IP addresses
(a botnet, rotating through cloud provider IPs, using proxy services)
can bypass a PURE per-IP rate limit entirely — each individual IP stays
comfortably under the threshold, while the TOTAL attempt rate against
one specific account remains high

Rate limiting purely by source IP address is a real, common but incomplete defense: a sufficiently motivated attacker distributing requests across many different IP addresses (a real, practical capability, not a theoretical edge case) keeps each individual IP under the per-IP threshold while the aggregate attack against one target account continues unabated. This is why rate limiting is often applied per meaningful account (or per email address being attempted) in addition to per IP — an account-level limit catches this bypass, since it doesn't matter how many different IPs are involved if they're all still trying the same target account.

Account lockout: a real trade-off, not a strictly better alternative

Rate limiting: slows down attempts, but a LEGITIMATE user who's simply
forgotten their password can still eventually get in (after the window
passes)

Account lockout: locks the account entirely after N failed attempts —
STRONGER protection, but creates a real, exploitable DENIAL-OF-SERVICE
vector: an attacker who knows a victim's email can deliberately lock
THEM out of their own account by intentionally failing login attempts

Full account lockout after repeated failures is a genuinely stronger defense against brute force, but it introduces a real, opposite risk: an attacker who only knows a victim's email address (not their password at all) can deliberately trigger enough failed attempts to lock the legitimate user out of their own account — a real denial-of-service against that specific user, achieved with zero actual password guessing required. This is exactly why many real systems use rate limiting combined with escalating delays (each failed attempt makes the next attempt wait progressively longer) rather than full lockout, balancing brute-force resistance against this exact self-inflicted denial-of-service risk.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is a login endpoint with no rate limiting vulnerable, even though every individual request behaves correctly?

2. Why is rate limiting purely by IP address an incomplete defense against brute force?

3. What real, opposite risk does full account lockout after failed attempts introduce?