DDoS Protection at the CDN Edge: Rate Limiting, Challenge Pages and Bot Detection

DDoS protection at the CDN edge has stopped being a marketing checkbox and become core infrastructure. When a 400 Gbps UDP amplification flood or a 2M RPS HTTP GET storm hits your origin directly, no amount of kernel tuning or autoscaling will save you. The packets have already saturated your transit link. In my experience running edge delivery for high-traffic media and commerce platforms, the only architecture that holds up is one where filtering, rate limiting, and bot interrogation happen in the same POP that terminates the client TCP connection — ideally before a request ever leaves the edge network. This article breaks down how modern CDNs implement that defense in practice, from rate limiting data structures to challenge pages that don't destroy conversion, and the bot signals that actually work at scale.

Why the Edge Is the Only Sane Place to Absorb Volumetric and Protocol Attacks

Volumetric attacks succeed by filling pipes, not by exploiting application logic. A typical origin might have two 10 Gbps transit providers. A single reflection attack using misconfigured NTP or memcached can generate 100+ Gbps from a modest botnet. If you try to filter that at the origin firewall, you've already lost — your upstream link is congested and legitimate packets are being dropped by your provider's policers upstream of you.

CDN edge networks solve this with two properties: massive aggregate capacity and distributed Anycast absorption. A Tier-1 CDN operates 200+ Tbps of edge capacity globally, announced from dozens of points of presence via Anycast. When an attacker floods 134.0.0.0/16, BGP naturally pulls traffic to the nearest POP, sharding the attack across dozens of scrubbing locations instead of concentrating it on one datacenter. I've watched a 1.2 Tbps carpet-bombing attack get absorbed with less than 15% CPU increase per POP because the load was spread across 60+ edges.

The Three Attack Layers You Must Handle Differently

Layer 3/4 floods (UDP, SYN, ACK) are filtered statelessly in hardware — eBPF/XDP filters, flow sampling, and BGP Flowspec rules that drop known bad patterns without touching your application. Layer 6/7 protocol abuse (Slowloris, HTTP/2 Rapid Reset, request smuggling) requires stateful termination and conformance checking against the HTTP specifications. And Layer 7 application floods (credential stuffing, cache-busting query strings, API scraping) look like legitimate requests until you analyze behavior over time. You cannot use the same tool for all three.

This layered model is why DNS routing matters so much. Your ability to route eyeballs and attackers independently starts with how you resolve users. I often recommend teams review Anycast DNS Architecture: How CDNs Route Users to the Nearest Edge before redesigning their scrubbing strategy, because if your DNS still points at a single origin IP, your edge filtering is bypassed entirely.

Edge filtering also protects the origin connection pool. During a sustained Layer 7 attack I handled in 2023, our origin saw 180k RPS before edge mitigation and 2.1k RPS after. The origin never scaled — we didn't need it to. The CDN kept 98.8% of junk from ever opening a connection back to our shield POP.

Token Bucket vs Sliding Window: Implementing Rate Limiting at the Edge POP

Rate limiting at the edge is not about nginx limit_req on a single box. It is a distributed counters problem with strict latency budgets. Every request must be evaluated in under 1-2ms, synchronized across 50-300 edge servers in a POP, and eventually reconciled globally without adding round trips.

The two algorithms that dominate production edge implementations are token bucket and sliding window log/counter. Token bucket is cheap and forgiving for bursty legitimate traffic. Sliding window is more precise and essential when you need to enforce hard limits for API abuse or login endpoints.

Choosing the Data Structure for Edge State

In my experience, a hybrid approach works best: token bucket for global per-IP and per-ASN limits, sliding window counters for sensitive paths like /login, /api/cart, and /search. You store counters in an in-memory shared datastore local to the POP — think Redis-like with CRDT sync or, in Fastly/Cloudflare style, in Rust/WASM shared memory with eventual consistency via gossip.

Key design decisions that separate working systems from those that break under attack:

Key cardinality: Limiting purely by IP fails under CGNAT and IPv6 /64 abuse. I've found that keying by (IP, JA3, path prefix) or (IP, /24, User-Agent class) catches distributed botnets without blocking an entire mobile carrier. For authenticated APIs, key by token fingerprint instead.

Counter sharding: Use consistent hashing to assign an IP to a counter owner within the POP. This avoids thundering-herd writes to a central store. If the owner is unreachable, fall back to local-only enforcement rather than fail-open.

Penalty decay: Never use fixed ban windows. An attacker will just cycle IPs every 5 minutes. Use exponential backoff with jitter on violations.

// Edge Worker (Cloudflare / Fastly Compute@Edge style) - sliding window counter
// Runs at the edge POP, evaluated before cache lookup
const WINDOW_MS = 60000;
const LIMIT = 120; // requests per minute for /api/search

async function rateLimit(request, env) {
  const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for");
  const key = `rl:${ip}:/api/search`;
  
  // Atomic increment with sliding window via Durable Object / KV
  const now = Date.now();
  const windowKey = Math.floor(now / WINDOW_MS);
  const count = await env.RATE_LIMIT_KV.incr(`${key}:${windowKey}`, 1, { expirationTtl: 120 });
  
  // Check current + previous window for smooth sliding
  const prevCount = await env.RATE_LIMIT_KV.get(`${key}:${windowKey - 1}`) || 0;
  const weight = 1 - (now % WINDOW_MS) / WINDOW_MS;
  const estimated = count + (prevCount * weight);

  if (estimated > LIMIT) {
    // Return 429 with Retry-After, do NOT forward to origin
    return new Response("Rate limit exceeded", {
      status: 429,
      headers: {
        "Retry-After": "15",
        "X-RateLimit-Limit": LIMIT.toString(),
        "X-RateLimit-Remaining": "0",
        "Content-Type": "text/plain"
      }
    });
  }
  return fetch(request); // pass through
}

This pattern enforces per-POP limits with eventual global accuracy. For true global limiting, you need asynchronous aggregation. I typically configure POP-local limits at 70% of the global threshold and sync counters to a central aggregator every 2-5 seconds. Per the HTTP Working Group Specifications for 429 handling, always include Retry-After and avoid using 503 for rate limiting — caches and clients treat them differently for retry logic.

Tuning Thresholds Without Breaking Legitimate Bursts

Static thresholds like "100 req/s per IP" will block corporate NATs and mobile gateways. In one retail deployment, a single office egress IP legitimately generated 400 req/s during a flash sale due to shared caching of images. My approach is to baseline for a week using log-only mode, then set limits at p99.9 + 40% headroom, with separate stricter limits for cache MISS vs HIT traffic. Attackers force cache bypass with random query strings, so a limit of 20 MISS/s per IP is often more effective than 200 total req/s.

Designing Challenge Pages That Filter Bots Without Punishing Humans

Challenge pages — interstitial JavaScript checks, cookie challenges, CAPTCHAs, and proof-of-work puzzles — are your middle tier between transparent rate limiting and hard blocking. Done poorly, they tank conversion and SEO. Done correctly, they shed 95% of dumb bot traffic with near-zero user impact.

The Challenge Ladder: From Silent to Interactive

I deploy challenges in escalating severity based on risk score, not as a single wall:

Level 1 - Silent cookie challenge: Edge returns a 302 with a __cf_chl-style cookie that requires no JS. Simple curl bots fail, browsers pass transparently on redirect. Latency cost is one extra round trip.

Level 2 - JavaScript proof-of-work: A lightweight SHA-256 loop that takes 30-80ms on a phone, 2-3 seconds on headless bot VMs. Forces the client to execute JS and burn CPU. This is my default for suspicious ASNs and high-risk paths.

Level 3 - Managed CAPTCHA: Only for repeated failures or when automation probability exceeds 0.85. Invasive puzzles should be the exception, not the rule.

The key is to never challenge known good traffic. Allowlist major search crawlers by verified ASN + reverse DNS, logged-in sessions with valid JWTs, and requests with prior passed challenges cached in an edge cookie for 30 minutes.

<!-- Lightweight PoW challenge page served directly from edge memory -->
<html>
<head><meta name="robots" content="index, follow, nofollow"></head>
<body>
<script>
(async () => {
  const challenge = "{{CHALLENGE_NONCE}}"; // injected at edge
  const difficulty = 4; // leading zeros required
  let nonce = 0, hash = "";
  const start = performance.now();
  // Worker thread would be better; simplified for clarity
  while (!hash.startsWith("0000")) {
    nonce++;
    const data = challenge + nonce;
    const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
    hash = [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
    if (nonce % 5000 === 0 && performance.now() - start > 3000) break; // fail open
  }
  // POST proof back to edge for verification, then set clearance cookie
  await fetch("/__challenge/verify", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ nonce, hash, challenge })
  });
  location.reload();
})();
</script>
<noscript>Please enable JavaScript to continue.</noscript>
</body>
</html>

Measuring the Business Cost of a Challenge

Every challenge has a conversion tax. I've measured 8-12% drop in session continuation when we inserted Level 2 challenges globally during a bot flood, even with sub-100ms puzzles. The fix was path-scoped challenges: only /checkout/*, /login, and /api/* under challenge mode, while static assets and PDP pages remained unchallenged. Also, never challenge your own monitoring — tag uptime probes with a header and bypass.

For SEO, ensure challenge pages return 403 or 503 with Retry-After rather than 200, and include Cache-Control: no-store, max-age=0. Otherwise search crawlers will index your challenge HTML. Guidance from web.dev Articles on interaction latency is useful here — keep the challenge execution under 100ms where possible to avoid hurting INP.

Behavioral Signals and Fingerprinting: Modern Bot Detection Behind Anycast

IP rate limiting alone was sufficient in 2016. It is trivially bypassed today by botnets with 500k residential proxies. Modern edge bot detection combines TLS fingerprinting, HTTP behavior analysis, and client-side signals into a risk score evaluated on every request at the edge.

Signals That Actually Survive Evasion

TLS and HTTP/2 fingerprinting: JA3/JA4 hashes, cipher suite ordering, ALPN, and HTTP/2 SETTINGS frames are difficult to spoof perfectly at scale. I log the tuple of (JA3, HTTP version, header order, TCP window size) per request. In one credential stuffing wave, 92% of malicious traffic shared the same JA3 despite 40k distinct IPs — all using the same Go HTTP library without mimicking a real browser. Blocking that JA3 with a challenge eliminated the flood instantly.

Behavioral sequence detection: Bots crawl predictably: / → /login within 80ms, or /sitemap.xml → 10k product pages at perfect 250ms intervals. Humans are erratic. I model this with simple per-session transition graphs at the edge. If a session hits /api/login 50 times without ever requesting /static/app.js or showing mouse movement, it is not a user.

Client-side attestation: Collecting screen size, timezone, canvas data, and interaction timing via a 3KB async JS snippet lets you distinguish headless Chrome from real Chrome. Do not block on a single missing signal — privacy browsers will trigger false positives. Instead, feed 12-15 weak signals into a weighted score.

# VCL / edge logic pseudocode for fingerprint scoring at ingress
sub vcl_recv {
    # Extract TLS fingerprint injected by edge terminator
    set req.http.X-JA3 = tls.client.ja3_hash;
    set req.http.X-H2-Fingerprint = http2.settings_fingerprint;

    # Score lookup from edge KV (updated by offline ML pipeline hourly)
    set req.http.X-Bot-Score = table.lookup(bot_scores, req.http.X-JA3, "0");

    # Combine with behavioral counters
    if (req.url ~ "^/api/login" && req.http.X-Bot-Score > 60) {
        # High-risk JA3 hitting sensitive endpoint
        call challenge_pow;
    }
    if (counter.count(req.http.X-Forwarded-For + ":login_attempts", 60s) > 15) {
        # Even with low score, brute force pattern triggers CAPTCHA
        call challenge_captcha;
    }
    # Known crawler verification - never block, only verify
    if (req.http.User-Agent ~ "Googlebot" && !verified_google_asn(client.ip)) {
        call challenge_silent_cookie;
    }
}

Operating the Scoring Pipeline

The scoring model should not run inline per request. In my deployments, edge nodes extract features and log them to a streaming pipeline (Kafka / Kinesis), an offline process computes reputation scores hourly, and the updated score map is pushed to all POPs via KV replication. Inline evaluation is then a simple table lookup — sub-millisecond. I've found that trying to run ML inference synchronously on every request adds 15-40ms p95 and creates a new DoS vector when the inference service slows down.

You also need a feedback loop. When a user solves a challenge, that fingerprint's score should decay. When a fingerprint fails three PoW challenges in a row, escalate its global score. Without decay, you will accumulate false positives and permanently block legitimate users behind carrier-grade NAT who inherited a dirty IP.

DNS as a First Line of Defense: Routing Dirty Traffic Away Before It Hits HTTP

Most teams think of DDoS protection as an HTTP problem, but your DNS layer controls where traffic goes before a single HTTP header is parsed. A well-designed edge bakes DDoS decisions into DNS routing.

Anycast DNS gives you automatic topological sharding, but you can also use DNS to steer. When our scrubbing layer detects a sustained L7 flood from a specific region or ASN, we publish a selective DNS response that directs those CIDRs to a heavily filtered scrubbing POP with stricter challenges, while clean traffic continues to the optimal low-latency POP. This is not crude DNS blackholing — it's weighted, latency-aware mitigation via control-plane signaling.

For teams operating multi-region origins, Global Server Load Balancing: GSLB Strategies for Multi-Region Deployments provides a solid foundation for how health-aware DNS can remove a burning origin from rotation without manual intervention. I extend that pattern: during a DDoS, GSLB health checks include edge load and attack severity, not just origin health. If POP-A is under 70% attack traffic, DNS automatically drains 50% of its eyeballs to POP-B via adjusted weights, giving the scrubbers breathing room.

Another practical technique is DNS-based tarpitting for apex floods. If attackers hammer your apex domain with non-existent subdomain queries (water torture / NXDOMAIN flood), the authoritative DNS edge must absorb it. Enable aggressive NSEC caching, limit wildcard synthesis, and deploy response rate limiting (RRL) at the DNS edge. The IETF specifications for DNS over HTTPS and DNS over TLS on IETF Datatracker also document how encrypting DNS transport reduces certain amplification vectors and should be part of your DNS edge hardening.

Finally, consider TTL strategy. During normal operation, a 60-second TTL gives you agility. Under attack, you may want to temporarily increase TTLs for clean traffic to reduce query load on your authoritative tier, while keeping attacker buckets on a 10-second TTL so you can rapidly shift them. I've used this during a 300M qps DNS flood to keep resolver load manageable without losing routing control.

Tuning Origin Shielding and Failover Logic Under Active DDoS Conditions

Your origin should never see raw internet traffic. Even with edge filtering, the link between edge and origin is a chokepoint attackers will try to exploit with cache-busting and range requests. Origin shielding — routing all cache misses through a single nominated shield POP per region — collapses thousands of potential origin connections into a few persistent, pre-warmed TLS sessions.

Under attack, shielding becomes survival. Without it, a cache-bypass attack with ?no-cache=<rand> causes every edge server to open its own origin connection, multiplying load by Nx. With shielding, only the shield POP contacts origin, and it can collapse duplicate requests via request coalescing.

The second critical control is failover. When the edge detects origin 5xx or latency spikes above 1.5 seconds (common when the origin DB is melting), you must not retry aggressively and amplify the problem. Configure stale-while-revalidate and stale-if-error serving at the edge, and implement circuit breaking on origin fetches.

When I design failover, I tie it directly to CDN Failover and Origin Redundancy: Ensuring Availability During Outages patterns: serve stale for 24 hours, queue background revalidation at one request per second, and shift write traffic to a secondary origin region via GSLB. This let us ride out a 40-minute origin database failover during an active Layer 7 flood with zero edge 503s seen by users.

Mitigation Technique Best For Latency Cost to Legit Users Evasion Resistance
IP / ASN Rate Limiting (Token Bucket) Volumetric floods, naive scrapers, API abuse from single prefix ~0-1 ms (header injection only) Low — easily bypassed with IP rotation
Challenge Page (PoW / JS Cookie) Distributed bots, headless browsers, credential stuffing 30-120 ms + one redirect Medium — defeats simple scripts, not advanced headless farms
Behavioral & TLS Fingerprint Scoring Residential proxy botnets, sophisticated scraping, account takeover ~1-2 ms (KV lookup) High — requires per-request mimicry at scale
Origin Shield + Stale Serving & Circuit Breaker Cache-bypass, origin overload, state-exhaustion attacks 0 ms (serves stale instantly) High — protects origin even if edge filtering is leaky

One last operational note: practice your mitigation runbooks under load. In my experience, the failure mode during a real incident is not that the WAF rule was wrong — it's that someone deployed the rule globally without a canary and locked out the payment webhook provider. Use traffic splitting to canary every new rule. Even a 1% weighted rollout as described in progressive delivery workflows can surface false positives before they become outages. Isolate changes to a single POP, validate for 5 minutes on real traffic, then expand.

Frequently Asked Questions

How do I rate limit without blocking legitimate users behind CGNAT or corporate NAT?

Never limit on IP alone. Combine IP with a secondary key such as JA3 hash, path, and session cookie. Use separate limits for cache-hit and cache-miss traffic — a NAT with 200 real users will generate many hits but few misses. Implement log-only baselining first, set thresholds at p99.9 + headroom, and provide a clearance cookie after a lightweight challenge so verified humans bypass IP limits for 30 minutes.

When should I block vs challenge vs rate limit?

Rate limit when you want to enforce a fair-use quota without hard failure, like API consumers. Challenge when bot probability is 0.5–0.85 and you want to verify humanity without denying service. Block only when confidence exceeds 0.9 or the request is protocol-invalid, such as malformed HTTP/2 or known exploit payloads. Blocking too eagerly creates support tickets and false positives that are harder to unwind than a challenge.

Can bot detection work if attackers rotate residential proxies and spoof headers perfectly?

Full header spoofing at scale is expensive and imperfect. Even when IPs and User-Agents rotate, TLS fingerprints, HTTP/2 settings, header ordering, and behavioral sequences tend to stay constant across the botnet. Correlating these signals and scoring them globally lets you identify a single actor behind 100k IPs. Client-side attestation with interaction timing adds another layer that is costly to fake in headless farms.

Does anycast DNS alone provide DDoS protection?

Anycast distributes and absorbs volumetric traffic, but it does not filter Layer 7 attacks that look like legitimate requests. You still need edge termination, request inspection, and application-layer scoring. Treat Anycast as your transport-layer sponge and build rate limiting, challenges, and fingerprint scoring on top. Without both layers, attackers will simply move up the stack from pipes to requests.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker