Published on

No Rate Limiting — One Angry User Can Take Down Your API

Authors

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

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-After header 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.