Rate Limiting & DoS Prevention: Code Review Guide
Table of Contents
1. Introduction to Rate Limiting & DoS Prevention
Rate limiting controls how many requests a client can make to an application within a given time period. Denial of Service (DoS) prevention ensures that an application remains available even when under abusive traffic. Together, they are essential for protecting authentication, APIs, and infrastructure from brute force, credential stuffing, scraping, and resource exhaustion attacks.
Why This Matters
Missing rate limiting is one of the most common findings in security assessments. Without it, an attacker can attempt millions of password guesses per hour, brute-force 2FA codes in minutes, exhaust server resources with expensive queries, and scrape entire databases through API endpoints. Rate limiting is listed in the OWASP API Security Top 10 as "Unrestricted Resource Consumption" (API4:2023).
In this guide, you'll learn how the major rate limiting algorithms work (token bucket, sliding window, leaky bucket), how to protect authentication endpoints against brute force and credential stuffing, how to design API rate limiting with proper headers and response codes, how application-layer DoS attacks exploit expensive operations, how to implement rate limiting in Node.js, Express, and Next.js, and how to detect missing rate limits during code review.
Rate Limiting Algorithms & Where They Apply
Common Algorithms
What Missing Rate Limiting Enables
What is the primary difference between rate limiting and DoS prevention?
2. Rate Limiting Algorithms
Different rate limiting algorithms offer different trade-offs between accuracy, memory usage, and burst handling. Understanding them helps you choose the right approach during code review.
Rate Limiting Algorithm Comparison
| Algorithm | How It Works | Burst Handling | Memory | Best For |
|---|---|---|---|---|
| Fixed Window | Count requests in fixed time windows (e.g., per minute) | Allows 2x burst at window boundary | Low | Simple limits where boundary spikes are acceptable |
| Sliding Window Log | Store timestamp of every request, count within rolling window | Smooth — no boundary issues | High (stores all timestamps) | Precise per-user limiting |
| Sliding Window Counter | Weighted average of current and previous window counts | Near-smooth with low memory | Low | Production API rate limiting |
| Token Bucket | Tokens added at fixed rate; each request costs tokens | Allows bursts up to bucket size | Low | API rate limiting with burst tolerance |
| Leaky Bucket | Queue processed at constant rate; overflow dropped | Smooths traffic to constant rate | Low-Medium | Traffic shaping, message queues |
Token Bucket algorithm implementation
1class TokenBucket {
2 constructor(capacity, refillRate) {
3 this.capacity = capacity; // Max tokens (burst size)
4 this.tokens = capacity; // Start full
5 this.refillRate = refillRate; // Tokens added per second
6 this.lastRefill = Date.now();
7 }
8
9 consume(tokens = 1) {
10 this.refill();
11
12 if (this.tokens >= tokens) {
13 this.tokens -= tokens;
14 return true; // Request allowed
15 }
16 return false; // Request rejected (rate limited)
17 }
18
19 refill() {
20 const now = Date.now();
21 const elapsed = (now - this.lastRefill) / 1000;
22 this.tokens = Math.min(
23 this.capacity,
24 this.tokens + elapsed * this.refillRate
25 );
26 this.lastRefill = now;
27 }
28}
29
30// Usage: 10 requests/second with burst of 20
31const bucket = new TokenBucket(20, 10);
32
33if (bucket.consume()) {
34 // Process request
35} else {
36 // Return 429 Too Many Requests
37}Sliding Window Counter (production-friendly)
1class SlidingWindowCounter {
2 constructor(windowMs, maxRequests) {
3 this.windowMs = windowMs;
4 this.maxRequests = maxRequests;
5 this.windows = new Map(); // key -> { current, previous, windowStart }
6 }
7
8 isAllowed(key) {
9 const now = Date.now();
10 const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
11
12 let entry = this.windows.get(key);
13 if (!entry || entry.windowStart < windowStart - this.windowMs) {
14 entry = { current: 0, previous: 0, windowStart };
15 this.windows.set(key, entry);
16 }
17
18 if (entry.windowStart < windowStart) {
19 entry.previous = entry.current;
20 entry.current = 0;
21 entry.windowStart = windowStart;
22 }
23
24 // Weighted count: previous window portion + current window count
25 const elapsed = now - windowStart;
26 const weight = 1 - elapsed / this.windowMs;
27 const count = entry.previous * weight + entry.current;
28
29 if (count >= this.maxRequests) return false;
30
31 entry.current++;
32 return true;
33 }
34}Your API allows 100 requests per minute using a Fixed Window counter. At 11:00:59, a client sends 100 requests (allowed). At 11:01:00, they send 100 more (new window — also allowed). What happened?
3. Brute Force & Credential Stuffing Prevention
Authentication endpoints are the highest-value targets for rate limiting. Without it, attackers can launch brute force attacks (trying password combinations) and credential stuffing attacks (testing leaked username/password pairs from data breaches).
Vulnerable: Login endpoint without rate limiting
1// ❌ VULNERABLE: No rate limiting on login
2app.post('/api/auth/login', async (req, res) => {
3 const { email, password } = req.body;
4 const user = await User.findOne({ email });
5
6 if (!user || !await bcrypt.compare(password, user.passwordHash)) {
7 return res.status(401).json({ error: 'Invalid credentials' });
8 }
9
10 // Attacker can try millions of passwords:
11 // - 1000 attempts/second with no delay
12 // - 4-digit PIN: cracked in < 10 seconds
13 // - 6-digit OTP: cracked in < 17 minutes
14 // - Common password list (10K): < 10 seconds
15
16 const token = generateToken(user);
17 res.json({ token });
18});Secure: Multi-layered login rate limiting
1import rateLimit from 'express-rate-limit';
2import RedisStore from 'rate-limit-redis';
3import { createClient } from 'redis';
4
5const redisClient = createClient({ url: process.env.REDIS_URL });
6
7// Layer 1: Global per-IP limit (prevents distributed attacks from one IP)
8const ipLimiter = rateLimit({
9 store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
10 windowMs: 15 * 60 * 1000, // 15 minutes
11 max: 50, // 50 login attempts per IP per 15 min
12 standardHeaders: true, // Return RateLimit-* headers
13 legacyHeaders: false,
14 message: { error: 'Too many login attempts. Try again later.' },
15});
16
17// Layer 2: Per-account limit (prevents targeted brute force)
18const accountLimiter = async (req, res, next) => {
19 const { email } = req.body;
20 if (!email) return next();
21
22 const key = `login_attempts:${email.toLowerCase()}`;
23 const attempts = await redisClient.incr(key);
24
25 if (attempts === 1) {
26 await redisClient.expire(key, 900); // 15 min TTL
27 }
28
29 if (attempts > 10) {
30 // After 10 failed attempts, lock the account temporarily
31 return res.status(429).json({
32 error: 'Account temporarily locked. Try again in 15 minutes.',
33 retryAfter: 900,
34 });
35 }
36
37 next();
38};
39
40app.post('/api/auth/login', ipLimiter, accountLimiter, async (req, res) => {
41 const { email, password } = req.body;
42 const user = await User.findOne({ email: email.toLowerCase() });
43
44 if (!user || !await bcrypt.compare(password, user.passwordHash)) {
45 return res.status(401).json({ error: 'Invalid credentials' });
46 }
47
48 // Reset counter on successful login
49 await redisClient.del(`login_attempts:${email.toLowerCase()}`);
50
51 const token = generateToken(user);
52 res.json({ token });
53});Endpoints That Need Rate Limiting
Beyond /login, these endpoints are frequently targeted and need rate limits: Password reset (prevent email flooding), Registration (prevent mass account creation), OTP/2FA verification (brute force 4–6 digit codes), API key generation, Email/SMS sending (prevent spamming), File upload (disk exhaustion), and Search/export (expensive queries).
You rate-limit the login endpoint to 5 attempts per minute per IP address. An attacker uses a botnet with 10,000 IP addresses to conduct credential stuffing. Does your rate limiting help?
4. API Rate Limiting
API rate limiting protects backend resources and ensures fair usage across all consumers. A well-designed API rate limiter communicates limits clearly via standard HTTP headers.
Standard rate limiting HTTP headers
1# Response headers (IETF draft standard: RateLimit fields)
2RateLimit-Limit: 100 # Max requests allowed in the window
3RateLimit-Remaining: 42 # Requests remaining in current window
4RateLimit-Reset: 1675209600 # Unix timestamp when window resets
5
6# When rate limited, return 429 status:
7HTTP/1.1 429 Too Many Requests
8Content-Type: application/json
9Retry-After: 30 # Seconds until the client should retry
10RateLimit-Limit: 100
11RateLimit-Remaining: 0
12RateLimit-Reset: 1675209600
13
14{
15 "error": "rate_limit_exceeded",
16 "message": "You have exceeded the rate limit of 100 requests per minute.",
17 "retryAfter": 30
18}Tiered API rate limiting by plan
1// Different rate limits based on API key / user plan
2const RATE_LIMITS = {
3 free: { windowMs: 60000, max: 20 }, // 20/min
4 basic: { windowMs: 60000, max: 100 }, // 100/min
5 pro: { windowMs: 60000, max: 500 }, // 500/min
6 enterprise: { windowMs: 60000, max: 5000 }, // 5000/min
7};
8
9async function apiRateLimiter(req, res, next) {
10 const apiKey = req.headers['x-api-key'];
11 const plan = await getUserPlan(apiKey);
12 const limits = RATE_LIMITS[plan] || RATE_LIMITS.free;
13
14 const key = `api:${apiKey}:${Math.floor(Date.now() / limits.windowMs)}`;
15 const current = await redis.incr(key);
16
17 if (current === 1) {
18 await redis.expire(key, Math.ceil(limits.windowMs / 1000));
19 }
20
21 // Set standard rate limit headers on ALL responses
22 res.set('RateLimit-Limit', limits.max);
23 res.set('RateLimit-Remaining', Math.max(0, limits.max - current));
24 res.set('RateLimit-Reset', Math.ceil(Date.now() / limits.windowMs) * limits.windowMs / 1000);
25
26 if (current > limits.max) {
27 res.set('Retry-After', Math.ceil(limits.windowMs / 1000));
28 return res.status(429).json({
29 error: 'rate_limit_exceeded',
30 message: `Rate limit of ${limits.max} requests per minute exceeded.`,
31 retryAfter: Math.ceil(limits.windowMs / 1000),
32 });
33 }
34
35 next();
36}Common API Rate Limiting Mistakes
Rate limiting only by API key: An attacker can create multiple free accounts to multiply their limits. Also rate-limit by IP. Not rate limiting unauthenticated endpoints: Public endpoints like search, listing, and health checks are often unprotected. Rate limiting after expensive processing: The rate check should happen BEFORE the request hits business logic, database queries, or external API calls.
5. Application-Layer DoS
Application-layer DoS attacks exploit expensive operations that consume disproportionate server resources. A single crafted request can consume more resources than thousands of normal requests. Rate limiting alone doesn't prevent these — you need resource-aware controls.
ReDoS: Regular expression denial of service
1// ❌ VULNERABLE: Catastrophic backtracking regex
2const emailRegex = /^([a-zA-Z0-9]+)+@([a-zA-Z0-9]+)+\.com$/;
3
4// Normal input: "user@example.com" — matches instantly
5// Malicious input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
6// → The regex engine backtracks exponentially
7// → 28 'a' characters = ~4 seconds
8// → 30 'a' characters = ~16 seconds
9// → 35 'a' characters = ~500 seconds (8+ minutes!)
10
11// Single request, single regex, server is frozen.
12
13// ✅ FIX: Use non-backtracking patterns or regex timeout
14const safeEmailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
15// Or use a validation library like validator.js / zodGraphQL query complexity DoS
1// ❌ VULNERABLE: Unbounded nested GraphQL query
2// A single request can fetch millions of records:
3const maliciousQuery = `
4 query {
5 users(first: 1000) {
6 posts(first: 1000) {
7 comments(first: 1000) {
8 author {
9 posts(first: 1000) {
10 comments(first: 1000) {
11 text
12 }
13 }
14 }
15 }
16 }
17 }
18 }
19`;
20// This query could resolve 1000 × 1000 × 1000 × 1000 × 1000 = 1 TRILLION records!
21
22// ✅ FIX: Query complexity analysis
23// Set maximum query depth (e.g., 5 levels)
24// Set maximum query complexity score
25// Set per-query timeout
26// Limit list sizes (max first: 100)Large payload / Zip bomb DoS
1// ❌ VULNERABLE: No request body size limit
2app.use(express.json()); // Default: 100KB — but may be overridden
3
4// Attacker sends a 100MB JSON body that consumes server memory
5
6// ❌ VULNERABLE: Decompressing user-uploaded files without limits
7const zip = await unzip(req.file);
8// A 42KB "zip bomb" expands to 4.5 PETABYTES!
9
10// ✅ FIX: Enforce strict limits
11app.use(express.json({ limit: '1mb' })); // Limit JSON body
12app.use(express.urlencoded({ limit: '1mb' })); // Limit form data
13
14// ✅ Limit file upload size
15const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 } }); // 5MB maxYour API has a rate limit of 100 requests per minute. An attacker sends a single request with a search query that triggers a full table scan on a 10 million row database, taking 30 seconds. Is your rate limit effective?
6. Implementation Patterns
Here are practical, production-ready implementation patterns for common frameworks.
Express.js: express-rate-limit with Redis
1import rateLimit from 'express-rate-limit';
2import RedisStore from 'rate-limit-redis';
3import { createClient } from 'redis';
4
5const redisClient = createClient({ url: process.env.REDIS_URL });
6await redisClient.connect();
7
8// Global API rate limiter
9const apiLimiter = rateLimit({
10 store: new RedisStore({
11 sendCommand: (...args) => redisClient.sendCommand(args),
12 }),
13 windowMs: 60 * 1000, // 1 minute
14 max: 100, // 100 requests per window
15 standardHeaders: true, // RateLimit-* headers
16 legacyHeaders: false, // Disable X-RateLimit-* headers
17 keyGenerator: (req) => {
18 // Use authenticated user ID if available, fallback to IP
19 return req.user?.id || req.ip;
20 },
21 skip: (req) => {
22 // Skip rate limiting for health checks
23 return req.path === '/health';
24 },
25});
26
27// Strict limiter for authentication endpoints
28const authLimiter = rateLimit({
29 store: new RedisStore({
30 sendCommand: (...args) => redisClient.sendCommand(args),
31 prefix: 'rl:auth:',
32 }),
33 windowMs: 15 * 60 * 1000, // 15 minutes
34 max: 10, // 10 attempts per 15 min
35 standardHeaders: true,
36 message: {
37 error: 'Too many authentication attempts. Please try again later.',
38 },
39});
40
41// Apply
42app.use('/api/', apiLimiter);
43app.use('/api/auth/login', authLimiter);
44app.use('/api/auth/register', authLimiter);
45app.use('/api/auth/reset-password', authLimiter);Next.js: Rate limiting in API routes / middleware
1// src/lib/rateLimit.ts
2import { Redis } from '@upstash/redis';
3
4const redis = new Redis({
5 url: process.env.UPSTASH_REDIS_URL!,
6 token: process.env.UPSTASH_REDIS_TOKEN!,
7});
8
9interface RateLimitResult {
10 allowed: boolean;
11 remaining: number;
12 reset: number;
13}
14
15export async function rateLimit(
16 key: string,
17 maxRequests: number,
18 windowSeconds: number
19): Promise<RateLimitResult> {
20 const window = Math.floor(Date.now() / 1000 / windowSeconds);
21 const redisKey = `rl:${key}:${window}`;
22
23 const current = await redis.incr(redisKey);
24 if (current === 1) {
25 await redis.expire(redisKey, windowSeconds);
26 }
27
28 return {
29 allowed: current <= maxRequests,
30 remaining: Math.max(0, maxRequests - current),
31 reset: (window + 1) * windowSeconds,
32 };
33}
34
35// Usage in Next.js API route:
36// src/app/api/data/route.ts
37import { rateLimit } from '@/lib/rateLimit';
38import { NextRequest, NextResponse } from 'next/server';
39
40export async function GET(request: NextRequest) {
41 const ip = request.headers.get('x-forwarded-for') || 'unknown';
42 const { allowed, remaining, reset } = await rateLimit(ip, 60, 60);
43
44 if (!allowed) {
45 return NextResponse.json(
46 { error: 'Rate limit exceeded' },
47 {
48 status: 429,
49 headers: {
50 'RateLimit-Remaining': remaining.toString(),
51 'RateLimit-Reset': reset.toString(),
52 'Retry-After': '60',
53 },
54 }
55 );
56 }
57
58 // Process request...
59 return NextResponse.json({ data: '...' });
60}7. Detection During Code Review
Missing rate limiting is one of the easiest vulnerabilities to identify during code review. Systematically check every endpoint that accepts user input or performs expensive operations.
Code Review Detection Patterns
| Endpoint / Pattern | What to Check | Risk Without Rate Limiting |
|---|---|---|
| POST /login, /auth, /signin | Is there per-IP AND per-account rate limiting? | Critical — brute force / credential stuffing |
| POST /register, /signup | Is account creation rate-limited per IP? | High — mass account creation / spam |
| POST /reset-password | Is email sending rate-limited per account and IP? | High — email flooding / account enumeration |
| POST /verify-otp, /verify-2fa | Is OTP verification rate-limited? (4-digit = 10K combos) | Critical — 2FA bypass in minutes |
| GET /api/* (public endpoints) | Are unauthenticated API endpoints rate-limited? | High — scraping / resource exhaustion |
| POST /upload | Is file size and upload frequency limited? | High — disk exhaustion DoS |
| POST /search, GET /search?q= | Are expensive search queries limited with timeouts? | High — database DoS |
| POST /graphql | Is query depth/complexity limited? | Critical — exponential resolution DoS |
| Any regex on user input | Could the regex cause catastrophic backtracking (ReDoS)? | High — single-request CPU DoS |
| JSON/XML body parsing | Is request body size limited? | Medium — memory exhaustion |
Quick grep patterns for missing rate limiting
1# Find authentication endpoints (likely need rate limiting)
2grep -rn "login|signin|authenticate|reset-password|verify-otp|register" --include="*.ts" --include="*.js" | grep -i "post|route|handler|app."
3
4# Check if rate limiting middleware is imported/used
5grep -rn "rate-limit|rateLimit|throttle|express-rate-limit|@upstash/ratelimit" --include="*.ts" --include="*.js"
6
7# Find request body parsing without size limits
8grep -rn "express.json()|bodyParser.json()" --include="*.ts" --include="*.js"
9
10# Find regex patterns (potential ReDoS)
11grep -rn "new RegExp|/.*[+*].*[+*].*/" --include="*.ts" --include="*.js"
12
13# Find GraphQL without complexity limits
14grep -rn "graphql|ApolloServer|buildSchema" --include="*.ts" --include="*.js"During code review, you find that the API has rate limiting on POST endpoints but not on GET endpoints. The team says GET requests are 'read-only and safe.' Should you flag this?