- Published on
No Rate Limiting — One Angry User Can Take Down Your API
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Rate limiting is one of those things you don't think about until after the incident. Either a malicious user hammers your API, a script accidentally goes into a tight retry loop, or a well-intentioned batch job floods your endpoint with 50 concurrent threads. Without rate limiting, your API has no floor — any single client can consume all your capacity.
- The Attack Vectors You're Not Thinking About
- Fix 1: Token Bucket Rate Limiting with Redis
- Fix 2: Different Limits for Different Endpoints
- Fix 3: IP-Based vs User-Based Limits
- Fix 4: Rate Limit the Login Endpoint Specifically
- Rate Limiting Checklist
- Conclusion
The Attack Vectors You're Not Thinking About
1. Credential stuffing: attacker tries 1M username/password combos against /auth/login
2. Enumeration: iterate through /users/1, /users/2... to scrape your database
3. Runaway client bug: retry loop with no backoff hammers /api/*
4. Competitive scraping: competitor pulls your entire product catalog
5. DoS: even without intent — a popular tweet linking your API demo
6. Brute force: trying all 10,000 common passwords against a known email
Fix 1: Token Bucket Rate Limiting with Redis
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
analytics: true,
})
// Express middleware
async function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
const identifier = req.user?.id ?? req.ip // authenticated users get per-user limits
const { success, limit, remaining, reset } = await ratelimit.limit(identifier)
// Always set rate limit headers (helps legitimate clients back off gracefully)
res.setHeader('X-RateLimit-Limit', limit)
res.setHeader('X-RateLimit-Remaining', remaining)
res.setHeader('X-RateLimit-Reset', new Date(reset).toISOString())
if (!success) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil((reset - Date.now()) / 1000),
})
}
next()
}
Fix 2: Different Limits for Different Endpoints
// Not all endpoints need the same limit — tune per sensitivity
// Auth endpoints: very strict (prevent brute force)
const authRatelimit = new Ratelimit({
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
analytics: true,
})
// General API: moderate
const apiRatelimit = new Ratelimit({
limiter: Ratelimit.slidingWindow(100, '1 m'),
})
// Read-heavy endpoints: more generous
const readRatelimit = new Ratelimit({
limiter: Ratelimit.slidingWindow(1000, '1 m'),
})
// Apply per endpoint
app.post('/auth/login', applyRateLimit(authRatelimit), loginHandler)
app.post('/auth/register', applyRateLimit(authRatelimit), registerHandler)
app.post('/auth/forgot-password', applyRateLimit(authRatelimit), forgotHandler)
app.post('/orders', applyRateLimit(apiRatelimit), createOrderHandler)
app.get('/products', applyRateLimit(readRatelimit), getProductsHandler)
Fix 3: IP-Based vs User-Based Limits
// IP-based: before auth (login, register, password reset)
async function ipRateLimit(req: Request, res: Response, next: NextFunction) {
const ip = req.headers['x-forwarded-for']?.toString().split(',')[0] ?? req.socket.remoteAddress
const { success } = await ratelimit.limit(`ip:${ip}`)
if (!success) return res.status(429).json({ error: 'Rate limit exceeded' })
next()
}
// User-based: after auth (more generous limits for paying customers)
async function userRateLimit(req: Request, res: Response, next: NextFunction) {
if (!req.user) return next() // unauthenticated hits IP limit only
// Pro users get higher limits
const limitPerMin = req.user.plan === 'pro' ? 1000 : 100
const ratelimit = new Ratelimit({
limiter: Ratelimit.slidingWindow(limitPerMin, '1 m'),
})
const { success } = await ratelimit.limit(`user:${req.user.id}`)
if (!success) return res.status(429).json({ error: 'Rate limit exceeded' })
next()
}
Fix 4: Rate Limit the Login Endpoint Specifically
// Login bruteforce protection: progressive delays + account lockout
const loginAttempts = new Map<string, { count: number; lockedUntil?: Date }>()
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body
const key = `${req.ip}:${email}` // per IP + email combination
const attempts = loginAttempts.get(key) ?? { count: 0 }
// Check lockout
if (attempts.lockedUntil && attempts.lockedUntil > new Date()) {
const retryAfterSecs = Math.ceil((attempts.lockedUntil.getTime() - Date.now()) / 1000)
return res.status(429).json({
error: 'Account temporarily locked',
retryAfter: retryAfterSecs,
})
}
const user = await authService.login(email, password)
if (!user) {
attempts.count++
// Progressive lockout: 5 attempts = 1 min, 10 attempts = 15 min, 20+ = 1 hour
if (attempts.count >= 20) {
attempts.lockedUntil = new Date(Date.now() + 60 * 60 * 1000)
} else if (attempts.count >= 10) {
attempts.lockedUntil = new Date(Date.now() + 15 * 60 * 1000)
} else if (attempts.count >= 5) {
attempts.lockedUntil = new Date(Date.now() + 60 * 1000)
}
loginAttempts.set(key, attempts)
return res.status(401).json({ error: 'Invalid credentials' })
}
// Success: reset attempts
loginAttempts.delete(key)
res.json({ token: authService.generateToken(user) })
})
Rate Limiting Checklist
- ✅ Every public API endpoint has rate limiting
- ✅ Auth endpoints (login, register, password reset) have strict limits (5-10/min)
- ✅ Rate limit headers (
X-RateLimit-Remaining) included in every response - ✅ 429 responses include
Retry-Afterheader so clients back off correctly - ✅ Authenticated users get per-user limits (not IP-based — proxies break IP limits)
- ✅ Rate limit events are logged and monitored (alerts on sustained 429 responses)
- ✅ Load test your rate limiting implementation before going live
Conclusion
Rate limiting is the seatbelt of API design — you don't appreciate it until you need it. Implement it before launch, not after your first incident. The minimum viable setup: a sliding window rate limiter backed by Redis, applied globally with lower limits on auth endpoints, with X-RateLimit-* headers so legitimate clients can respect it. Upstash Ratelimit, express-rate-limit, or any Redis-backed sliding window implementation gets you there in under 30 minutes.