Rate Limiting in Production: The Most Underrated Security Control
Ten lines of code that stop 90% of automated abuse — credential stuffing, brute force, scraping, API abuse. Here's how to actually do it.
Rate limiting is the most underrated security control. It won't stop a determined attacker with a botnet, but it will stop 90% of automated abuse — credential stuffing, scraping, brute force, API abuse — for about ten lines of code.
Here's how to actually do it.
What rate limiting actually defends against
Rate limiting caps how many requests a single source (IP, user, API key) can make in a time window. The attacks it stops:
- Credential stuffing — automated login attempts with leaked password lists. Without rate limiting, an attacker can try 10,000 passwords in minutes.
- Brute force — systematic guessing of passwords, OTPs, or tokens. Rate limiting turns a 10-minute attack into a 10-year attack.
- Scraping — automated extraction of your content or data. Rate limiting makes it slow enough to be uneconomical.
- API abuse — expensive endpoints (search, reports, exports) being called in a loop to drive up your costs or exhaust resources.
- Resource exhaustion — the slow cousin of DoS. A single client opening thousands of connections or sending slow requests to exhaust your pool.
What rate limiting doesn't stop: a distributed attack from millions of IPs (that's DDoS — you need a CDN/edge). And it doesn't protect against logic bugs (a single request that crashes your server).
The four dimensions of a rate limit
A rate limit is defined by four things:
- What you limit by — IP address, user ID, API key, session, or some combination. IP is the default but breaks down behind NAT (a whole office shares one IP) and with bots (millions of IPs). User/API key is better but requires auth.
- The window — fixed window (e.g., 100 requests per minute, resets at :00), sliding window (100 requests in any rolling 60-second period), or token bucket (a bucket of tokens that refills at a fixed rate). Sliding window and token bucket are smoother; fixed window is simpler but allows bursts at boundaries.
- The limit — how many requests per window. Too low and you block legitimate users; too high and you don't stop abuse. Start conservative and adjust based on real traffic.
- The response — 429 Too Many Requests with a
Retry-Afterheader is the standard. Some APIs return 503; that's wrong — 429 is specifically "you're being rate limited, try again later."
Where to rate limit
Three layers, each with different purposes:
1. Edge / CDN layer (Cloudflare, Vercel, AWS WAF)
Rate limit at the edge before traffic hits your server. This is the cheapest, most effective layer — it stops attacks before they cost you compute. Cloudflare's free plan includes basic rate limiting; paid plans add custom rules.
Limit by: IP, ASN, country, JA3 fingerprint. Good for: Volumetric attacks, scrapers, automated abuse from a single source.
2. Application / API layer (your code or middleware)
Rate limit in your app or API gateway, by user or API key. This is where you enforce business rules — "free tier: 100 requests/hour, paid: 10,000/hour."
Limit by: authenticated user ID, API key, session. Good for: Per-user quotas, protecting expensive endpoints, enforcing plan limits.
Implementation: a middleware that checks a counter in Redis (or Upstash, or your DB) before the request reaches your handler. Libraries exist for every framework — express-rate-limit, slowapi (FastAPI), rack-attack (Rails).
3. Endpoint-specific layer
Some endpoints need stricter limits than the global default:
- Login / password reset: 5-10 attempts per minute per IP, per email. Strict.
- Signup / email verification: 3-5 per hour per IP. Prevents account-creation spam.
- Search / expensive queries: 10-30 per minute per user. Prevents abuse.
- Export / download: 1-5 per hour per user. These are expensive.
- File upload: 10-20 per hour per user. Prevents zip-bomb-style attacks.
The response: 429 with headers
When you rate limit, return a proper response:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1672531200
{ "error": "rate_limited", "message": "Too many requests. Try again in 60 seconds." }
Retry-After— seconds until the client can retry. Required by the spec.X-RateLimit-*headers — non-standard but widely used. Helps clients build polite retry logic.- A clear error code in the body —
rate_limitedis more useful than a generic "error."
Don't return 500 or 503 — those mean "server error" or "unavailable," not "you're being throttled." Clients handle them differently.
Token bucket vs fixed window
The two most common algorithms:
Fixed window: "100 requests per minute, resets at :00." Simple. But allows a burst of 200 at the boundary (100 at :59, 100 at :00).
Token bucket: A bucket holds N tokens. Each request consumes one. Tokens refill at R per second. Allows bursts up to N, then smooths to R/sec. More flexible, slightly more complex.
For most apps, token bucket is the better default — it handles bursts gracefully (a user clicking rapidly won't get blocked) while still capping sustained abuse. Upstash Ratelimit, Cloudflare, and most libraries support it.
The storage problem
Rate limiting requires shared state — a counter that all your server instances can see. Options:
- Redis / Upstash Redis — the standard. Fast, atomic operations. Upstash has a free tier and a serverless HTTP API (no connection pool needed).
- In-memory — only works if you have one server. Doesn't work in serverless (each invocation is isolated) or multi-instance setups.
- Database — works but slow. Only for very low-volume limits.
If you're on serverless (Vercel, Netlify, Lambda), you must use an external store like Upstash Redis. In-memory rate limiting silently does nothing in serverless.
What to log
When you rate limit, log it:
- The IP / user / key that was limited
- The endpoint
- The timestamp
- The window and limit that was hit
This lets you spot patterns: "IP 1.2.3.4 is being rate-limited on /login every 30 seconds — that's a credential stuffing attempt, block it at the edge." Without logs, rate limiting is invisible.
The 80/20
If you do nothing else:
- Edge rate limiting via Cloudflare (free) — 100 requests/minute per IP, site-wide.
- Login endpoint — 10 attempts/minute per IP, 5 per email.
- API endpoints — 100 requests/minute per authenticated user, using Upstash Redis.
- Return proper 429s with
Retry-After.
That's the minimum. It stops 90% of automated abuse for an afternoon of work.
Run the ShipReady Security checklist — rate limiting is one of the 18 items in the Security section.
Run the full checklist
Put this into practice — generate a stack-specific production readiness checklist and track your progress, free.
Open the checklistGet new articles in your inbox
Practical production readiness guides. No spam, unsubscribe anytime.
Join developers shipping safer. We email ~2× per month.
Keep reading
What Is a Zip Bomb? The 42KB File That Crashes Servers (DoS Explained)
A 42 KB file that expands to 4.5 petabytes. Here's how zip-bomb denial-of-service attacks work, the real numbers behind the famous 42.zip, and how to defend against them.
12 Security Headers You Must Set Before Going to Production
CSP, HSTS, X-Frame-Options and the rest. A plain-English reference for every HTTP security header your web app should set before launch.
DoS vs DDoS vs Decompression Bomb: A Developer's Field Guide
They're not the same attack, and they don't have the same defense. Here's the difference — and the interview-ready way to explain it.