DDoS JavaScript Challenges at CDN Edge: Filtering Bots Without Blocking Users

Last winter I watched a customer's origin collapse in under 90 seconds — not from a massive volumetric flood, but from 40,000 headless browsers hammering their login API. Their WAF was green, rate limits looked fine, but every request had a perfect User-Agent and rotating residential IPs. We flipped on a JavaScript challenge at the edge and the attack traffic dropped 97% while real users barely noticed. That's the whole point of a well-built JS challenge: it doesn't try to out-muscle bots, it forces them to prove they can actually run a browser.

Why Edge-Based JavaScript Challenges Outperform Static Filters

Static filters — IP reputation, User-Agent checks, simple rate limits — worked fine five years ago. They don't anymore. Modern bot toolkits like Puppeteer Extra and Playwright with stealth plugins can solve basic TLS and header checks without breaking a sweat. I’ve seen botnets that even rotate through real Chrome fingerprints pulled from compromised devices. If you're only looking at headers at the edge, you're essentially trusting whatever the client tells you, and attackers know it.

A JavaScript challenge flips that model. Instead of guessing if a client is legitimate, you give it a small, time-bound computation to execute and return. A real browser does this in milliseconds without any user interaction. A simple cURL script, a Python requests loop, or a cheap booter service that can't run a full JS engine just fails. It's not a CAPTCHA — users don't have to click anything — but it creates a cryptographic gap that most DDoS Application Layer Attack Techniques: Slowloris, RUDY, and HTTP Floods can't cross cheaply. We've had far more success stopping HTTP floods this way than by trying to tune another regex for a fake Chrome 120 header.

The trick is executing it entirely at the edge, before the request ever touches your origin. At the CDN layer you can intercept, serve the challenge, validate the proof, and set a cookie — all in under 30ms. That keeps your origin shielded and prevents the cache-bypass problem where bots intentionally add random query strings. The HTTP Working Group Specifications are useful here for understanding how to handle caching headers correctly while serving these interstitial responses, so you don't accidentally cache a challenge page.

// Simplified edge worker: issuing a JS challenge
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(req) {
  const cookie = req.headers.get('Cookie') || '';
  if (cookie.includes('__js_challenge_valid=1')) {
    // Validate HMAC and expiry server-side before passing through
    if (await isValidChallengeCookie(cookie)) {
      return fetch(req);
    }
  }

  // No valid cookie -> serve challenge
  const token = crypto.randomUUID();
  const proofTarget = generateProofTarget(token); // e.g., SHA-256 prefix

  return new Response(renderChallengeHTML(token, proofTarget), {
    status: 403,
    headers: {
      'Content-Type': 'text/html',
      'Cache-Control': 'no-store, no-cache',
      'Set-Cookie': `__js_challenge_token=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=300`
    }
  });
}

function renderChallengeHTML(token, target) {
  // Client must find nonce where SHA-256(token + nonce) starts with target
  return `<html><script>
    (async () => {
      let nonce = 0;
      while(true) {
        const hash = await sha256('${token}' + nonce);
        if(hash.startsWith('${target}')) break;
        nonce++;
      }
      document.cookie="__js_challenge_valid=1; path=/";
      location.reload();
    })();
  </script></html>`;
}

How Client Validation Separates Real Browsers From Headless Bots

Not all challenges are created equal, and I learned that the hard way. Early on we deployed a challenge that was just `eval(atob(...))` with a 5-second delay. It blocked the dumb bots, but it also punished users on slow Android devices and got hammered by replay attacks. Attackers simply solved it once and reused the cookie across their whole fleet. You’ve got to make the challenge non-reusable, non-transferable, and cheap for legitimate clients.

A solid implementation today combines three things: a cryptographic proof-of-work, a binding to the client's TLS fingerprint or IP subnet, and a short expiry. Our current default asks the browser to brute-force a SHA-256 prefix — usually 4-5 hex characters — which takes a modern phone about 20-40ms but would cost a botnet doing 100k rps an enormous amount of CPU. We bind the resulting cookie to a hash of the initial token plus the client’s /24 and JA3 hash, so a stolen cookie can't be sprayed across different nodes. And we rotate the secret used to sign the validation cookie every 60 seconds.

There’s also the question of what to do with clients that can't run JavaScript at all. You can't just block every API client, search crawler, or accessibility tool. We maintain an allowlist for known good bots and use a fallback path for API routes — those get a stricter rate limit and behavioral check instead of a JS hurdle. That’s a key difference between defending against bandwidth floods and application-layer abuse. For pure pipe saturation you need different tactics, as covered in DDoS Volumetric Attack Patterns: Identifying and Absorbing Bandwidth Floods, but for L7 floods, a JS challenge is often your first and least intrusive line of defense. Guidance from web.dev Articles helped us tune the challenge script to avoid blocking rendering or triggering long-task warnings in real browsers.

// Validation at edge - check proof before issuing signed cookie
async function validateChallenge(request) {
  const url = new URL(request.url);
  const token = getCookie(request, '__js_challenge_token');
  const nonce = url.searchParams.get('nonce');

  if (!token || !nonce) return false;

  const hash = await sha256(token + nonce);
  // Require hash starts with '0000' - adjust difficulty per load
  if (!hash.startsWith('0000')) return false;

  // Bind cookie to client network + prevent replay
  const clientBind = await sha256(token + request.headers.get('CF-Connecting-IP').split('.').slice(0,3).join('.'));
  const expiry = Date.now() + 30 * 60 * 1000; // 30 min validity

  const payload = `${clientBind}.${expiry}`;
  const signature = await hmacSign(payload, EDGE_SECRET);

  // Issue signed validation cookie
  return {
    headers: {
      'Set-Cookie': `__js_challenge_valid=${payload}.${signature}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800`,
      'Clear-Cookie': '__js_challenge_token=; Max-Age=0'
    }
  };
}

Tuning Difficulty Without Punishing Legitimate Traffic

The biggest mistake I see teams make is setting a challenge to "high" and leaving it there. If your difficulty is too aggressive, you'll spike Time to First Byte for real users, especially on mid-range devices or spotty mobile connections. If it's too easy, a botnet with a few headless Chrome instances will chew right through it. We treat JS challenge difficulty like a load balancer — dynamic, not static.

During normal traffic we don't challenge at all, or we sample 1-2% to establish a baseline. When our anomaly detection sees a surge in 403s, origin latency, or requests per ASN, we ramp the challenge difficulty from 3 hex zeros to 4, then 5, depending on pressure. We also use progressive challenges: first request gets a trivial proof, failed or suspicious clients get a harder one that also checks for browser APIs like `chrome`, `webdriver`, or `navigator.plugins` inconsistencies. This keeps the median overhead for a real user under 50ms.

You have to measure this constantly. We log four metrics for every challenge deployment: solve rate, median solve time, false-positive rate from our synthetic monitoring, and origin offload. If your false-positive rate climbs above 0.5%, you’re burning users to stop bots, and that’s a losing trade. That's also why reflected attacks need a totally separate strategy — you can't JS-challenge spoofed UDP packets. For that, you’ll want DDoS DNS Amplification Defense: Stopping Reflected Attacks at CDN Scale alongside any client validation you do at L7.

Challenge Mode Median Solve Time (Real Browser) Bot Solve Rate False Positive Rate Edge CPU Overhead
Passive (3-char prefix) 12 ms 38% (with headless Chrome) 0.05% < 1 ms
Standard (4-char prefix + bind) 28 ms 6.2% 0.21% 1.2 ms
Aggressive (5-char + API checks) 145 ms 0.8% 1.40% 2.8 ms
CAPTCHA Fallback 4.2 s (human interaction) 1.1% 3.80% 0.5 ms

Implementing JavaScript Challenges at the Edge Without Killing Cache Hit Ratio

Here’s where most teams shoot themselves in the foot: they deploy the challenge logic and accidentally bypass cache for every request. You don’t want that. Your challenge should run *before* the cache lookup, but only intercept suspicious traffic. On Cloudflare Workers, Fastly Compute, or AWS CloudFront Functions, we typically gate it behind a bot score or rate threshold. For example, if your edge firewall flags an IP with >80 requests per 10 seconds or a JA3 fingerprint that doesn’t match a real browser, then — and only then — you serve the challenge. Legit users with clean reputations sail straight to cache. That keeps your hit ratio above 92% even during a 500k RPS L7 flood.

Once a client passes the challenge, don’t make them solve it again on every page load. That’s a great way to spike your bounce rate by 15-20%. Issue a cryptographically signed cookie — we use a short-lived HMAC token with a 30-minute TTL, rotated every 6 hours — and validate it at the edge on subsequent requests. The validation is cheap, under 1ms of CPU at the edge, versus 40-60ms to serve and verify the full JavaScript proof-of-work. Store the token in a `__cf_chl` or `__edge_challenge` cookie with `HttpOnly`, `Secure`, and `SameSite=Lax`. And please, set a 3.5-second timeout on the challenge itself. If a real browser on a 3G connection can’t solve it in under 4 seconds, your difficulty is too high. We’ve found that 800-1,200ms of intentional work is the sweet spot — enough to melt headless bot farms, painless for users.

Unlike volumetric vectors that you can just drop at the edge, JavaScript challenges are for application-layer abuse — they won’t help you with pure amplification floods. If you’re getting hammered by UDP reflection, you need dedicated network-layer filtering like DDoS NTP Reflection Mitigation: Blocking Monlist-Based Amplification Attacks or DDoS Memcached Amplification Defense: Neutralizing 51,000x Amplification Factor first, then let the JS challenge handle the botnet that follows. Here’s a stripped-down Worker pattern we use in production to keep it lean:

// Edge Worker: JS Challenge with signed cookie bypass
const SECRET = 'rotate-this-weekly-via-kv';
const CHALLENGE_TTL = 1800; // 30 minutes
const DIFFICULTY = 4; // leading zeros required, ~900ms on avg device

async function handleRequest(req) {
  const cookie = getCookie(req, '__edge_chl');
  if (cookie && await verifyHMAC(cookie, SECRET)) {
    return fetch(req); // Valid token, go to cache/origin
  }

  // Only challenge suspicious traffic
  const score = req.cf ? req.cf.botManagement.score : 50;
  const rate = await getRate(req.headers.get('cf-connecting-ip'));
  if (score > 30 && rate < 80) {
    return fetch(req);
  }

  // Serve lightweight challenge (approx 12KB gzipped)
  if (req.url.includes('__chl_answer')) {
    const isValid = await validateProof(req, DIFFICULTY);
    if (isValid) {
      const token = await signHMAC(`${Date.now()}|${ip}`, SECRET);
      return new Response('OK', {
        status: 302,
        headers: {
          'Set-Cookie': `__edge_chl=${token}; Max-Age=${CHALLENGE_TTL}; Path=/; HttpOnly; Secure; SameSite=Lax`,
          'Location': req.headers.get('Referer') || '/'
        }
      });
    }
    return new Response('Challenge failed', { status: 403 });
  }
  return new Response(generateChallengeHTML(DIFFICULTY), {
    headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }
  });
}

Advanced Patterns and Troubleshooting When Challenges Fail Open (or Closed)

You’ve deployed, but now support tickets are rolling in: “Site won’t load in my corporate browser” or “Challenge loop on Safari.” First thing we check isn’t the challenge — it’s the content security policy and third-party script blockers. If your CSP blocks `unsafe-inline` and your challenge relies on an inline `