Rate Limiting for Abuse Prevention
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:
- 429 rate per endpoint — sudden spikes indicate attack or client misconfiguration.
- Limit hit distribution — which layer triggers most often.
- False positive rate — legitimate users receiving 429s.
- Global request volume — baseline deviation detection.
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:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
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:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- 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
- Redis rate limiting patterns
- IETF RateLimit header fields draft
- OWASP API Security Top 10 — Unrestricted Resource Consumption
- Cloudflare rate limiting documentation
- Stripe rate limiter design
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 →