Skip to main content

🚦 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-down and 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.

graph TD A[Security: CIA Triad] --> B[Confidentiality] A --> C[Integrity] A --> D[Availability] D --> D1[Rate Limiting] D --> D2[DDoS Protection] D --> D3[Load Balancing] D --> D4[Autoscaling]

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:

CategoryExamplesRight response
Legitimate spikesViral post, flash sale, product launch, seasonal peakAutoscale, cache, load-balance
Automated abuseAggressive scrapers, retry-loop clients, parallel botsRate limit, require auth/keys
Deliberate DoSSYN/UDP floods, HTTP floods, Slowloris, amplificationDefense in depth + CDN/WAF
A distributed denial-of-service attack: many compromised machines flood a single web server with traffic 🖥️ 🖥️ 🖥️ Botnet Web server overwhelmed flood of requests →
A DDoS attack pours traffic from many sources at once, so blocking a single IP does little. Defense has to happen in layers, ideally before traffic reaches your server.

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.

graph TD A[Rate-Limiting Algorithms] --> B[Fixed Window] A --> C[Sliding Window] A --> D[Token Bucket] B --> B1[Simplest, but burst at window edges] C --> C1[Smoother, more memory] D --> D1[Allows controlled 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.

graph TD A[DDoS Defense Layers] --> B[Network / Edge] A --> C[Server] A --> D[Application] B --> B1[CDN & WAF] B --> B2[Traffic scrubbing] C --> C1[Connection timeouts] C --> C2[Load balancing] D --> D1[Rate limiting] D --> D2[Body size limits] D --> D3[Request validation]

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

graph LR A[Detect] --> B[Classify] B --> C[Contain] C --> D[Communicate] D --> E[Recover] E --> F[Post-mortem]

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 429 with Retry-After and 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-limit and express-slow-down cover 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

🚀 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.