Rate Limiting for Abuse Prevention

SecurityRate LimitingAPIAbuse Prevention
Share on LinkedIn

A credential-stuffing botnet distributed 50,000 login attempts across 10,000 IPs — each staying well under your 100-requests-per-minute-per-IP limit. Your rate limiter reported all green. Per-client throttling is necessary but not sufficient; abuse prevention requires thinking about what happens when the attacker has unlimited clients. Rate limiting is the floor, not the ceiling, of API protection.

Rate limiting algorithms

Fixed window — count requests per clock-aligned window (e.g., per minute). Simple but allows 2x burst at window boundaries.

# Redis fixed window
def is_allowed(key: str, limit: int, window_seconds: int) -> bool:
    pipe = redis.pipeline()
    pipe.incr(key)
    pipe.expire(key, window_seconds)
    count, _ = pipe.execute()
    return count <= limit

Sliding window log — store timestamp of each request, count within rolling window. Accurate, more memory.

def sliding_window_allowed(key: str, limit: int, window_ms: int) -> bool:
    now = time.time() * 1000
    pipe = redis.pipeline()
    pipe.zremrangebyscore(key, 0, now - window_ms)
    pipe.zadd(key, {str(now): now})
    pipe.zcard(key)
    pipe.expire(key, int(window_ms / 1000) + 1)
    _, _, count, _ = pipe.execute()
    return count <= limit

Token bucket — tokens refill at a steady rate; requests consume tokens. Allows bursts up to bucket capacity.

def token_bucket_allowed(key: str, capacity: int, refill_rate: float) -> bool:
    now = time.time()
    data = redis.hgetall(key)
    tokens = float(data.get("tokens", capacity))
    last = float(data.get("last", now))
    tokens = min(capacity, tokens + (now - last) * refill_rate)
    if tokens >= 1:
        redis.hset(key, mapping={"tokens": tokens - 1, "last": now})
        return True
    return False

Layered rate limits

Apply limits at multiple scopes:

Layer Key Limit example Purpose
Global global:endpoint 10,000/min Protect infrastructure
Per-IP ip:{addr} 100/min Stop single-source floods
Per-user user:{id} 60/min Fair usage per account
Per-API-key key:{hash} 1,000/hour Enforce plan tiers
Per-endpoint user:{id}:login 5/min Protect sensitive ops

A request must pass all applicable layers. Login endpoints get the tightest per-user limits; read endpoints get looser ones.

def check_rate_limits(request) -> bool:
    checks = [
        ("global", "global:api", 10_000, 60),
        ("ip", f"ip:{request.ip}", 100, 60),
        ("user", f"user:{request.user_id}", 60, 60),
        ("endpoint", f"user:{request.user_id}:{request.path}", endpoint_limit(request.path), 60),
    ]
    for name, key, limit, window in checks:
        if not sliding_window_allowed(key, limit, window * 1000):
            log_rate_limit_hit(name, key, request)
            return False
    return True

Beyond per-client limits

Distributed attacks require aggregate signals:

Global anomaly detection — alert when total login failures across all IPs spike 5x above baseline, even if no single IP exceeds limits.

Progressive challenges — after N failures from any identifier (IP, user, fingerprint), require CAPTCHA or proof-of-work before processing more requests.

IP reputation — block or throttle requests from known-bad IP ranges, Tor exit nodes, or datacenter IPs hitting consumer endpoints.

Account lockout — per-account failed login counter independent of IP. 10 failures from 10 different IPs still locks the account.

def login_handler(request):
    if not check_rate_limits(request):
        return Response(429, "Too many requests")

    if is_ip_flagged(request.ip):
        return require_captcha(request)

    if account_failure_count(request.username) > 5:
        return Response(423, "Account temporarily locked")

    # proceed with authentication

Response headers and client behavior

Return standard rate limit headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 32
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704067200

Clients and API consumers depend on Retry-After for backoff. Include X-RateLimit-* on successful responses too so clients can self-throttle before hitting limits.

Distributed enforcement with Redis

All application instances must share state:

# Use Redis Cluster or single Redis with persistence
redis = Redis(host="redis.internal", decode_responses=True)

# Lua script for atomic sliding window (prevents race conditions)
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, math.ceil(window / 1000))
    return 1
end
return 0
"""

Run Redis in sentinel or cluster mode for HA. Rate limiter downtime means either blocking all traffic (safe) or allowing unlimited traffic (dangerous). Choose safe.

Monitoring and tuning

Dashboard and alert on:

Review limits quarterly against actual usage patterns. Limits set during launch often do not match traffic after 10x growth.

Common production mistakes

Teams get rate limiting abuse prevention wrong in predictable ways:

Production implementations of rate limiting abuse prevention fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Debugging and triage workflow

When rate limiting abuse prevention misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

What is the difference between rate limiting and abuse prevention?

Rate limiting caps request volume per client within time windows — a necessary baseline. Abuse prevention is broader: detecting credential stuffing, scraping patterns, bot behavior, and coordinated attacks that stay under per-client rate limits. Production systems layer rate limits with behavioral analysis, CAPTCHAs, IP reputation, and account-level controls.

Which rate limiting algorithm should I use?

Sliding window log gives the most accurate rate enforcement and works well for strict API limits. Token bucket allows controlled bursts while maintaining average rate — good for user-facing APIs where occasional bursts are normal. Fixed window is simplest but suffers from boundary spikes. Choose based on whether you need burst tolerance or strict counting.

How do I rate limit in a distributed system?

Use a shared store — Redis is the standard — so all application instances enforce the same counters. Redis INCR with TTL for fixed windows, sorted sets for sliding windows, or Redis Cell module for token buckets. Never rate limit per-instance with in-memory counters; clients routed to different instances will exceed limits.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →