Rate Limiting for API Security: Block Abuse and Brute Force
Use rate limiting as a security control against brute-force, credential stuffing, scraping, and L7 DDoS. Layered limits, per-endpoint tuning, and 429 handling.
Overview
Rate limiting earns its place in a security stack because most automated abuse shares one trait: volume. Credential stuffing needs thousands of login attempts. Scraping needs every page, fast. An L7 flood needs a request rate you never planned for. A limiter cuts all of these off at the source by making volume expensive.
This recipe treats rate limiting as a defense layer, not a traffic-shaping feature. The implementation algorithms (token bucket, sliding window, fixed window) are covered step by step in API rate limiting and Redis-backed rate limiting. What follows is the security side: which layer blocks what, how to identify clients attackers can’t trivially rotate, how strict each endpoint should be, and what to do after a client trips the limit.
When to use it
Deploy rate limiting when you face:
- Brute-force or credential-stuffing attacks against
/login,/auth/token, password reset, or OTP endpoints - Scraping of catalogs, search results, or user data at machine speed
- L7 request floods that stay under the radar of network-layer DDoS protection
- Cost abuse, where a single client burns through expensive operations — search, exports, AI calls, SMS sending
- API-key or token leakage, where a stolen credential is getting hammered from one source
Don’t rely on it when:
- The attack is volumetric (L3/L4) — that’s what your CDN/WAF network defenses absorb
- Traffic comes from a small set of trusted internal services you control — use authentication and separate quotas instead
- The endpoint is legitimately high-volume, like a health check or webhook receiver — a generic limit will create false alarms
Choosing limits by attack type
Different abuse needs different limits and different keys:
| Attack | What trips it | Recommended limit | Key on |
|---|---|---|---|
| Brute-force login | Repeated failures on one account | 5 attempts/min | IP + account |
| Credential stuffing | Many accounts from one source | 10 attempts/min | IP (+ fingerprint) |
| OTP / reset abuse | Code-guessing volume | 3-5 attempts/min | Account + IP |
| Scraping | Sequential bulk reads | 60-100 req/min | API key / user ID |
| L7 flood | Request rate alone | 10-20 req/s burst | IP |
| Cost abuse | Calls to expensive ops | 10-30/hour | User ID + endpoint |
Two rules of thumb: the more sensitive the endpoint, the tighter and more specific the limit; and the key should be something the attacker can’t rotate cheaply (IPs are cheap, accounts are less so, verified tokens least of all).
Solution
Atomic token bucket in Redis (Lua)
The classic pitfall is doing read-modify-write in two round trips — under concurrency, requests interleave and the limiter lets more through than it should. Run the check-and-decrement atomically inside Redis:
-- token_bucket.lua
-- KEYS[1] = rate limit key (e.g. "rl:login:1.2.3.4")
-- ARGV[1] = capacity, ARGV[2] = refill rate (tokens/sec)
-- ARGV[3] = current unix timestamp, ARGV[4] = key TTL (sec)
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last_refill = tonumber(bucket[2]) or tonumber(ARGV[3])
tokens = math.min(tonumber(ARGV[1]),
tokens + (tonumber(ARGV[3]) - last_refill) * tonumber(ARGV[2]))
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return allowed
import time
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
TOKEN_BUCKET = r.register_script(open('token_bucket.lua').read())
def is_allowed(key: str, capacity: int, refill_rate: float) -> bool:
return bool(TOKEN_BUCKET(keys=[key],
args=[capacity, refill_rate, time.time(), 60]))
# Block brute-force on login: 5 attempts/minute per IP + account
key = f"rl:login:{client_ip}:{account}"
if not is_allowed(key, capacity=5, refill_rate=5/60):
return Response(status=429, headers={'Retry-After': '12'},
body=b"Rate limit exceeded")
Tiered limits with rate-limiter-flexible (Express)
Attack surface varies per route, so the limiter should too — strict on auth, lenient on reads:
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('redis')
.createClient({ url: process.env.REDIS_URL });
// Auth endpoints: 5 attempts/min, then block 5 min
const authLimiter = new RateLimiterRedis({
storeClient: redisClient, keyPrefix: 'auth',
points: 5, duration: 60, blockDuration: 300,
});
// General API: 100 requests/10s
const apiLimiter = new RateLimiterRedis({
storeClient: redisClient, keyPrefix: 'api',
points: 100, duration: 10,
});
function createLimiter(limiter, keyFn = (req) => req.ip) {
return async (req, res, next) => {
try {
await limiter.consume(keyFn(req), 1);
next();
} catch (rej) {
const retryAfter = Math.ceil(rej.msBeforeNext / 1000);
res.set('Retry-After', String(retryAfter));
res.set('X-RateLimit-Remaining', '0');
res.status(429).json({ error: 'Too many requests', retryAfter });
}
};
}
app.use('/api/', createLimiter(apiLimiter));
// Key on IP + credential: stops stuffing one source across many accounts
app.use('/api/auth/',
createLimiter(authLimiter, (req) => `${req.ip}:${req.body?.email || ''}`));
Edge limits in Nginx
Cheap, early rejection before requests reach your app:
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/auth/ {
limit_req zone=auth burst=3 nodelay;
limit_req_status 429;
limit_req_log_level warn; # feeds your attack detection
proxy_pass http://backend;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
error_page 429 = @ratelimited;
location @ratelimited {
add_header Retry-After "60" always;
return 429 '{"error": "Too many requests", "retryAfter": 60}';
}
}
Verifying the limits work
Before calling it done, hit an endpoint past its limit and confirm the behavior — a limiter that’s silently misconfigured protects nothing:
# Burst 10 requests; expect the tail to come back 429
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " \
-X POST https://api.example.com/api/auth/login \
-d '{"email":"a@b.c","password":"x"}'
done
# Expected: 200/401 a few times, then 429s with Retry-After
Tuning per endpoint
Start conservative and loosen where legitimate users complain:
- Auth and recovery endpoints — 3-5/min keyed on IP + account. These almost never see legitimate bursts.
- Costly reads (search, exports, reports) — 10-30/hour per user.
- Regular reads — 100+/min per user or token; scraping harm here is slow anyway.
- Writes and mutations — 20-50/min; mass writes are more damaging than mass reads.
Escalate rather than hard-block: first offense → 429 with a short Retry-After; repeated trips → longer blockDuration; sustained abuse → feed the source to your WAF or captcha challenge. Permanent IP bans are a last resort — shared NATs and mobile carrier gateways mean you’ll ban innocent users sharing the address.
What works
- Send back 429 with
Retry-AfterandX-RateLimit-*headers — honest clients back off on their own; attackers learn nothing from the response. - Key on who, not just where: authenticated user ID, API key, or session token. Limit by IP alone and NATs punish innocents while botnets sail through.
- Alert on 429 spikes through your monitoring stack. A burst of rejections is usually the first sign of an attack in progress, not a client bug.
- Pair with WAF and auth controls: the limiter slows attackers, it doesn’t authenticate them. Combine with security headers, strong auth, and captcha for repeat offenders.
- Fail closed on auth endpoints: if Redis is down, deny login/reset by default and only fail open on low-risk reads.
Common mistakes
- Keying on IP alone when a proxy sits in front —
req.ipthen reports the proxy, not the client. ParseX-Forwarded-Forfrom the trusted hop only, or a rotating attacker resets their budget for free. - Non-atomic counter updates — read-then-write race conditions let bursts slip through. Use Lua or a library that wraps the check in one atomic call.
- Same limit everywhere — one global ceiling either starves legitimate users or leaves auth endpoints exposed. Limits must match endpoint sensitivity.
- Limiting only at the edge — a compromised or buggy internal service bypasses the gateway. Keep app-layer limits too.
- Never expiring keys — sliding-window timestamps and counters accumulate forever. Every key needs a TTL.
Further Reading
- Nginx
limit_reqmodule documentation - RFC 6585 — HTTP Status Code 429
- Redis rate limiting pattern
- rate-limiter-flexible (Node.js)
- Bucket4j (Java)
See Also
- API Rate Limiting — implementation walkthrough for the algorithms used here
- Rate Limiting with Redis — distributed coordination in depth
- API Security Checklist — where rate limiting sits in a full security posture
Frequently Asked Questions
Can an attacker just rotate IPs to bypass rate limiting?
Only if the key is IP alone — that fails against botnets and proxy rotation. Combine IP with account identifier, fingerprint, or token: rotation then costs fresh credentials instead of fresh IPs, which is far more expensive.
Does rate limiting stop DDoS?
It helps against L7 floods — requests that look like real traffic but arrive too fast. Volumetric L3/L4 attacks need upstream filtering (CDN, scrubbing centers) before the traffic reaches your servers at all.
What's the difference between rate limiting, throttling, and a WAF?
Rate limiting rejects requests past a threshold (429). Throttling slows them down instead. A WAF inspects content — payloads, signatures, patterns. They're complementary: WAF for what requests contain, rate limiting for how many arrive.
Should authenticated and anonymous users get different limits?
Yes. Authenticated users get keyed limits by user ID — fairer and harder to game. Anonymous traffic gets IP-based limits with lower ceilings, since it's the highest-abuse-risk group.
What should I log when a limit trips?
Which key tripped it, the endpoint, the timestamp, and whether it's a repeat offender. Feed 429s into your SIEM and the limiter doubles as a detection sensor — a client that keeps hitting the wall after being told to back off is telling you something.
Related Resources
Secure APIs with HTTP Security Headers
How to configure essential security headers like HSTS, CSP, and X-Frame-Options to protect APIs and web applications from common attacks.
RecipeProtect Web Forms Against CSRF Attacks
How to prevent Cross-Site Request Forgery attacks using synchronizer tokens, SameSite cookies, and double-submit cookie patterns.
RecipeImplement API Rate Limiting with Redis
Protect APIs from abuse with Redis-backed token bucket and sliding window limiters, including burst handling and distributed coordination
RecipeDistributed Rate Limiting with FastAPI and Redis
Implement distributed rate limiting in FastAPI using Redis sliding window and token bucket algorithms with per-user, per-IP, and per-endpoint limits.
PatternToken Bucket Rate Limiter: Smooth Traffic with Token Buckets
How to implement token bucket rate limiting for API protection — bucket refill, burst handling, per-key quotas, and distributed limits with Redis.
GuideComplete Guide to API Security
Secure your APIs end-to-end. Covers rate limiting, authentication, input validation, CORS, SQL injection prevention, API gateway patterns, request size limits, pagination security, mass assignment, versioning, audit logging, and API security testing with practical code examples.