🚦 Rate Limiting and DDOS Protection
A perfectly secure app that nobody can reach has still failed. Availability is the third pillar of security — and the one attackers hit with brute-force login attempts, aggressive scrapers, and floods of traffic. In this lesson you'll learn to meter requests with rate limiting and to blunt denial-of-service attacks with layered defenses, so your service stays up when it counts.
Week 9 · Day 5 (Friday: Security Best Practices) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why availability is a core security goal (the CIA triad)
- Compare the main rate-limiting algorithms — fixed window, sliding window, token bucket
- Apply global and route-specific limits with
express-rate-limit - Add graceful throttling with
express-slow-downand scale it with Redis - Design DDoS defense-in-depth across network, server, and application layers
- Draft an incident response plan and emergency mitigations
Estimated Time: 75 minutes
Practice: Build a multi-tier rate-limiting setup and reason about its trade-offs.
In This Lesson
Availability Is Security
Information security rests on the CIA triad: Confidentiality, Integrity, and Availability. The first two lessons this week guarded confidentiality and integrity — keeping data private and unaltered. This one guards availability: keeping the service reachable. A login form that's impenetrable but permanently overloaded protects nobody.
Downtime is expensive in ways that compound: lost revenue, eroded user trust, damaged SEO, and breached SLAs. And crucially, an outage doesn't require a malicious attacker — a viral post or an accidental retry loop can flood you just as effectively as a botnet.
💥 Real-world impact: Amazon Prime Day 2018
Amazon's own retail site suffered intermittent outages during a 2018 Prime Day sale, with analysts estimating losses in the tens of millions of dollars over a few hours. The cause was insufficient capacity for a legitimate traffic spike — not an attack. Traffic management is a business-continuity concern, not just an anti-hacker one.
Traffic Challenges
Not all heavy traffic is hostile. Sorting it into three buckets helps you choose the right response:
| Category | Examples | Right response |
|---|---|---|
| Legitimate spikes | Viral post, flash sale, product launch, seasonal peak | Autoscale, cache, load-balance |
| Automated abuse | Aggressive scrapers, retry-loop clients, parallel bots | Rate limit, require auth/keys |
| Deliberate DoS | SYN/UDP floods, HTTP floods, Slowloris, amplification | Defense in depth + CDN/WAF |
Rate-Limiting Algorithms
Rate limiting caps how many requests a client may make in a time window. Before reaching for a library, it helps to understand the three algorithms behind them — they differ in accuracy, memory, and how they handle bursts.
Fixed window
Count requests per clock interval (e.g. per minute). Simple, but a client can send a full window's worth at 10:00:59 and another full window at 10:01:00 — double the limit across the boundary.
class FixedWindowLimiter {
constructor(maxRequests, windowMs) {
this.max = maxRequests;
this.windowMs = windowMs;
this.clients = new Map(); // clientId -> { windowStart, count }
}
isAllowed(clientId) {
const now = Date.now();
const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
const entry = this.clients.get(clientId);
if (!entry || entry.windowStart !== windowStart) {
this.clients.set(clientId, { windowStart, count: 1 });
return true;
}
if (entry.count < this.max) { entry.count++; return true; }
return false; // limit exceeded
}
}
Sliding window log
Store a timestamp per request and count only those inside the trailing window. Precise and no edge bursts, but uses more memory.
class SlidingWindowLimiter {
constructor(maxRequests, windowMs) {
this.max = maxRequests;
this.windowMs = windowMs;
this.clients = new Map(); // clientId -> number[] of timestamps
}
isAllowed(clientId) {
const now = Date.now();
const cutoff = now - this.windowMs;
const times = this.clients.get(clientId) || [];
// Drop timestamps that have aged out of the window
while (times.length && times[0] <= cutoff) times.shift();
if (times.length < this.max) {
times.push(now);
this.clients.set(clientId, times);
return true;
}
return false;
}
}
Token bucket
A bucket refills tokens at a steady rate; each request spends one. It permits short bursts (spend the accumulated tokens) while enforcing a long-term average — which is why it's the most popular choice for APIs.
class TokenBucketLimiter {
constructor(bucketSize, refillPerMs) {
this.size = bucketSize;
this.refillPerMs = refillPerMs; // tokens added per millisecond
this.clients = new Map();
}
isAllowed(clientId, cost = 1) {
const now = Date.now();
let b = this.clients.get(clientId);
if (!b) { b = { tokens: this.size, last: now }; this.clients.set(clientId, b); }
// Refill based on elapsed time, capped at bucket size
b.tokens = Math.min(this.size, b.tokens + (now - b.last) * this.refillPerMs);
b.last = now;
if (b.tokens >= cost) { b.tokens -= cost; return true; }
return false; // not enough tokens
}
}
💡 Which one should I use?
In practice you'll rarely hand-code these — a library picks a sound algorithm for you. But knowing them explains the trade-offs: fixed window is cheap but bursty, sliding window is accurate but heavier, token bucket balances both and tolerates legitimate bursts gracefully.
Rate Limiting in Express
The express-rate-limit middleware handles the mechanics — counting, headers, and the 429 Too Many Requests response — in a few lines.
const rateLimit = require('express-rate-limit');
// Global limit: 100 requests per IP per 15 minutes
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true, // send RateLimit-* headers (the modern standard)
legacyHeaders: false, // drop the old X-RateLimit-* headers
message: 'Too many requests, please try again later.'
});
app.use(limiter);
Apply stricter limits to sensitive or expensive routes — this is your front-line defense against credential-stuffing and brute-force login attacks:
// Only 5 login attempts per IP per hour — blunts brute force
const loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: 'Too many login attempts, please try again in an hour.'
});
app.post('/login', loginLimiter, handleLogin);
// A separate, looser budget for the general API
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
app.use('/api/', apiLimiter);
Instead of a hard cutoff, express-slow-down adds latency as a client approaches the limit — a gentler nudge that's less likely to break legitimate bursts:
const slowDown = require('express-slow-down');
const speedLimiter = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 50, // first 50 requests are full speed
delayMs: (hits) => hits * 100 // then add 100ms per extra request
});
app.use('/api/', speedLimiter);
What the client sees when limited
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 840
Retry-After: 840
Distributed & Dynamic Limits
An in-memory limiter breaks the moment you run more than one server instance — each process keeps its own count, so the real limit multiplies by the number of instances. The fix is a shared store like Redis.
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'api',
points: 10, // 10 requests
duration: 1 // per second, shared across ALL instances
});
app.use(async (req, res, next) => {
try {
await limiter.consume(req.ip); // spend 1 point
next();
} catch (err) {
res.set('Retry-After', String(Math.ceil((err.msBeforeNext || 1000) / 1000)));
res.status(429).json({ error: 'Too Many Requests' });
}
});
You can also vary the limit by who the client is — key on the user id when authenticated (fairer than IP, which lumps everyone behind a NAT together), and give paying tiers a bigger budget:
const tieredLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: (req) => (req.user?.tier === 'premium' ? 1000 : 100),
keyGenerator: (req) => req.user?.id || req.ip // user id beats raw IP
});
⚠️ IP-only limiting has blind spots
Many users share one public IP (corporate NAT, mobile carriers, university networks), so an IP-based limit can lock out an entire building — while an attacker with many IPs sails past it. Prefer authenticated identifiers where you can, and combine IP limits with per-account limits.
💡 Real-world example: GitHub's API limits
GitHub's REST API allows unauthenticated clients 60 requests per hour per IP, and authenticated users 5,000 per hour — with a secondary limit to catch abusive bursts. Every response carries headers reporting the limit, remaining budget, and reset time, so well-behaved clients can self-throttle. Clear limits plus clear headers is the pattern to copy.
DDoS Defense in Depth
A Distributed Denial of Service attack comes from many machines at once, so no single control stops it. You need layers, each catching what the previous one missed — and ideally most of the traffic is absorbed before it ever reaches your origin server.
Edge and network layer
The most effective DDoS defense usually isn't your code at all — it's a CDN/WAF (Cloudflare, AWS Shield + WAF, Fastly, Akamai) sitting in front of your origin. Their globally distributed networks absorb volumetric floods and scrub malicious packets long before they reach you.
Server layer (example: Nginx)
http {
# Cap connections and request rate per IP
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_req_zone $binary_remote_addr zone=reqs:10m rate=5r/s;
# Starve slow-loris style attacks of patience
client_body_timeout 10s;
client_header_timeout 10s;
reset_timedout_connection on;
server {
location /login {
limit_req zone=reqs burst=5 nodelay;
limit_conn perip 10;
}
}
}
Application layer
Inside Express, combine helmet, tight body limits, rate limiting, slow-down, and timeouts so one expensive endpoint can't be weaponised:
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
app.use(helmet());
app.use(express.json({ limit: '100kb' })); // reject oversized bodies
app.use(express.urlencoded({ extended: true, limit: '100kb' }));
app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
// Bound expensive operations so they can't pile up
app.get('/search', (req, res) => {
const timer = setTimeout(() => res.status(503).send('Search timed out'), 5000);
performSearch(req.query)
.then((r) => { clearTimeout(timer); res.json(r); })
.catch(() => { clearTimeout(timer); res.status(500).send('Search error'); });
});
💥 Real-world impact: the GitHub Memcached DDoS (2018)
GitHub weathered a 1.35 Tbps amplification attack — one of the largest ever at the time. Their monitoring detected it within minutes, an automated failover routed traffic through a scrubbing provider, and the site was back to normal in about ten minutes. The takeaway: detection plus an automated response plan matters as much as raw capacity.
Monitoring & Response
Prevention is never perfect, so you also need to see attacks and have a plan ready. You can't detect an anomaly without first knowing your normal baseline.
What to monitor
- Request rate per endpoint and per client
- Resource use — CPU, memory, network, database load
- Error rates — a spike in 4xx/5xx often precedes an outage
- Geographic and fingerprint anomalies — sudden traffic from unusual sources
An incident response plan
Emergency mitigations you can pre-build: temporary IP/geo blocks, tighter rate limits, feature degradation (turn off expensive endpoints), circuit breakers, and a CDN "under attack" mode. Wiring load-adaptive limits ahead of time means the response is a config flip, not a scramble:
const os = require('os');
// Tighten limits automatically as server load climbs
function currentMax() {
const loadPerCpu = os.loadavg()[0] / os.cpus().length;
if (loadPerCpu > 0.85) return 20; // aggressive
if (loadPerCpu > 0.70) return 50; // moderate
return 100; // normal
}
app.use((req, res, next) => {
rateLimit({ windowMs: 60 * 1000, max: currentMax() })(req, res, next);
});
💡 Rehearse before you need it
A response plan you've never tested is a document, not a defense. Run tabletop exercises and simulated load tests, keep the escalation contacts current, and make sure the person on call knows how to enable the CDN's attack mode at 3 a.m.
Practice & Quiz
🏋️ Exercise 1: Layered rate limits
Goal: Configure an Express app with a global limit of 100 requests/minute per IP, a strict 5-per-hour limit on /login, and a clear message plus standard headers on both.
💡 Hint
Create two separate rateLimit instances with different windowMs/max. Apply the global one with app.use(...) and the login one as route middleware on app.post('/login', ...). Set standardHeaders: true.
✅ Solution
const rateLimit = require('express-rate-limit');
const globalLimiter = rateLimit({
windowMs: 60 * 1000, max: 100,
standardHeaders: true, legacyHeaders: false,
message: 'Too many requests, slow down.'
});
app.use(globalLimiter);
const loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 5,
standardHeaders: true, legacyHeaders: false,
message: 'Too many login attempts, try again in an hour.'
});
app.post('/login', loginLimiter, handleLogin);
🏋️ Exercise 2: Fairer keying
Goal: Write a keyGenerator that rate-limits authenticated users by their user id and falls back to IP for anonymous requests. Explain in one sentence why this is fairer than IP alone.
✅ Solution
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.user?.id ?? req.ip
});
// Fairer because many users can share one IP (NAT/proxy), so keying on
// the user id avoids penalising everyone behind a shared address.
🎯 Quick Quiz
Question 1: Which HTTP status code signals "rate limit exceeded"?
Question 2: Why use Redis for rate limiting instead of an in-memory Map?
Question 3: What best describes DDoS "defense in depth"?
Best Practices & Pitfalls
✅ Do
- Set tighter limits on sensitive routes (login, password reset, search)
- Return
429withRetry-Afterand rate-limit headers so clients can back off - Use a shared store (Redis) once you run more than one instance
- Prefer progressive throttling (slow-down) over hard cutoffs where possible
- Put a CDN/WAF in front for volumetric attacks; layer your defenses
- Know your baseline, monitor for anomalies, and rehearse your response plan
❌ Don't
- Rely on IP-only limits — they punish shared networks and miss distributed attacks
- Keep counters in memory across multiple instances (each undercounts)
- Forget body-size limits and timeouts — cheap, high-value DoS mitigations
- Treat rate limiting alone as full DDoS protection
- Wait until an attack to design your response — build it in advance
✅ Layer, don't stack blindly
Edge (CDN/WAF) → server (timeouts, connection caps) → application (rate limits, validation). Each layer handles what it's best at, and legitimate users pass through all of them unnoticed while abuse gets shed early.
Summary
🎉 Key Takeaways
- Availability is a security goal — the "A" in the CIA triad
- Rate limiting meters requests; token bucket balances bursts with a steady average
express-rate-limitandexpress-slow-downcover most app-level needs- Scale limits across instances with Redis; key on user id, not just IP
- DDoS defense is layered — CDN/WAF at the edge, plus server and app controls
- Monitoring and a rehearsed incident response plan turn an outage into a blip
📚 Additional Resources
- express-rate-limit — official docs
- rate-limiter-flexible — Redis-backed limiting
- OWASP — blocking brute-force attacks
- Cloudflare — what is a DDoS attack?
- MDN — 429 Too Many Requests
🚀 What's Next?
You've now covered the whole security toolkit for Week 9 — vulnerabilities, CORS and CSP, and availability. Time to put it all together: the next lesson is a capstone where you build a secure authentication system with multiple strategies, applying hashing, sessions, rate limiting, and secure headers in one real project.
🚦 Still standing
Confidentiality, integrity, and now availability — you can keep an app safe and reachable under pressure. That's what production-grade security really means.