SurSolver

BlogAWS WAF Challenge with Playwright: the aws-waf-token cookie

AWS WAF Challenge with Playwright: the aws-waf-token cookie

· Updated · 5 min read

An AWS WAF Challenge does not show a puzzle. When a request lacks an acceptable token, the Challenge action can answer with a short stub instead of the page — HTTP 202 with x-amzn-waf-action: challenge. This post assembles the complete Playwright flow for it: capture the page's challenge.js URL, create the task, set the returned token as the aws-waf-token cookie, and prove the protected page really serves.

What the solve returns — and what it does not

An AwsChallengeCookie result carries:

  • token — the value for the aws-waf-token cookie on the protected origin;
  • userAgent — the browser identity the solve presented, used for the replay.

It returns no cookie jar, and it is not a form token: the value travels as a cookie. It is also specific to the Challenge action. AWS WAF CAPTCHA is a separate flow that renders a puzzle and records its own completion; a Challenge token says nothing about a CAPTCHA puzzle, and this task name does not select it.

No proxy is involved: the task requires no caller proxy details, and there is no proxyless/proxied naming split to choose between.

Find the challenge.js URL

scriptUrl is the one input you have to capture yourself. Open the protected URL in a browser and watch the Network panel: the request that runs the challenge is a challenge.js URL — first-party on the protected site or hosted on an AWS WAF SDK endpoint — and it carries the identifiers of that integration.

The example below collects the URL automatically by listening to the page's requests; a SCRIPT_URL constant overrides the capture when you would rather paste the URL yourself.

The contract constrains the value: it must use HTTPS, its path must end in /challenge.js, and it must not contain a URL fragment. A loader path guessed from another site's example does not describe your integration.

The Playwright flow

aws-challenge-playwright.js
// aws-challenge-playwright.js - solve AWS Challenge and open the protected page with the
// aws-waf-token cookie.
// Install: npm install playwright && npx playwright install chromium
// Run: node aws-challenge-playwright.js

const { chromium } = require('playwright');
const { setTimeout: sleep } = require('node:timers/promises');

// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The protected page that answers with the challenge.
const TARGET_URL = 'https://example.com/page';
// Optional, read from the page when empty: the challenge.js request of the challenged page
// (DevTools, Network).
const SCRIPT_URL = '';
// A selector that matches only after the site accepted the result.
const SUCCESS_SELECTOR = '.PASTE_ACCEPTED_SELECTOR';

const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;

// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
  return data;
}

async function solve(task) {
  const created = await call('/createTask', { clientKey: CLIENT_KEY, task });

  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
    const result = await call('/getTaskResult', {
      clientKey: CLIENT_KEY,
      taskId: created.taskId,
    });
    if (result.status === 'ready') return result.solution;
    await sleep(3000);
  }
  throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}

// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
  return Object.fromEntries(
    Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
  );
}

// A value the page did not show must be pasted into its constant at the top.
function need(name, value) {
  if (value === undefined || value === null || value === '') {
    throw new Error(`Could not read ${name} from the page; fill in the ${name} constant.`);
  }
  return value;
}

// Opens the page once and reads what the solve needs from it.
async function readPage(browser) {
  const page = await browser.newPage();
  const requests = [];
  page.on('request', (request) => requests.push(request));
  try {
    await page.goto(TARGET_URL, {
      waitUntil: 'domcontentloaded',
      timeout: 45000,
    });
    // Requests made after load count too; wait for them, but never hang on a busy page.
    await page.waitForLoadState('networkidle', { timeout: 5000 })
      .catch(() => undefined);
    const urls = (matches) =>
      [...new Set(requests.filter(matches).map((request) => request.url()))];
    return {
      pageUrl: page.url(),
      scriptUrl: urls((request) => /\/challenge\.js(\?|$)/.test(request.url()))[0],
    };
  } finally {
    await page.close();
  }
}

async function main() {
  const browser = await chromium.launch();
  try {
    const found = await readPage(browser);
    const solution = await solve({
      type: 'AwsChallengeCookie',
      websiteURL: found.pageUrl,
      scriptUrl: need('scriptUrl', SCRIPT_URL || found.scriptUrl),
    });

    // Replay with the user agent the solve presented.
    const context = await browser.newContext(
      solution.userAgent ? { userAgent: solution.userAgent } : {},
    );
    await context.addCookies([
      {
        name: 'aws-waf-token',
        value: solution.token,
        domain: new URL(found.pageUrl).hostname,
        path: '/',
        secure: true,
      },
    ]);
    const page = await context.newPage();
    await page.goto(found.pageUrl, { waitUntil: 'domcontentloaded' });
    await page.locator(SUCCESS_SELECTOR).first().waitFor({ timeout: 15000 });
    console.log('The site served the protected page.');
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Step by step:

  1. The page is opened once while the script records requests, and the challenge.js URL is taken from what the page actually loaded.
  2. An AwsChallengeCookie task is created with websiteURL (the protected URL) and scriptUrl.
  3. Polling continues every three seconds until the status is ready. A terminal ERROR_FAILED or ERROR_TASK_TIMEOUT fails loudly instead of falling through to the replay.
  4. The replay opens a fresh context whose user agent is the solve's userAgent, and adds the token as the aws-waf-token cookie on the page's hostname (secure, path /).
  5. The protected URL loads again and SUCCESS_SELECTOR — an element only the real page contains — must appear. The stub page has no such element, so a wrongly chosen or still-blocked URL fails the wait instead of reporting a false success.

The stub-versus-page contrast is the quickest sanity check while integrating: the same request without the token returns the short challenge response; with it, the full document.

Node.js

Without a browser the flow shrinks to solve and replay: paste the challenge.js URL into the SCRIPT_URL constant and run node aws-challenge-node.js — Node 18 or newer, no packages. The script sets the returned token as the aws-waf-token cookie of its own request, presents the solve's userAgent, and reads the same stub-versus-page contrast from the response.

aws-challenge-node.js
// aws-challenge-node.js - solve AWS Challenge and request the protected page with the
// aws-waf-token cookie.
// Install: Node.js 18 or newer (no packages).
// Run: node aws-challenge-node.js

const { setTimeout: sleep } = require('node:timers/promises');

// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The protected page that answers with the challenge.
const TARGET_URL = 'https://example.com/page';
// The challenge.js request of the challenged page (DevTools, Network).
const SCRIPT_URL = 'https://example.com/challenge.js';
// Text that appears only in a response the site accepted.
const SUCCESS_TEXT = 'PASTE_ACCEPTED_MARKER';

const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;

// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
  return data;
}

async function solve(task) {
  const created = await call('/createTask', { clientKey: CLIENT_KEY, task });

  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
    const result = await call('/getTaskResult', {
      clientKey: CLIENT_KEY,
      taskId: created.taskId,
    });
    if (result.status === 'ready') return result.solution;
    await sleep(3000);
  }
  throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}

// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
  return Object.fromEntries(
    Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
  );
}

async function main() {
  const solution = await solve({
    type: 'AwsChallengeCookie',
    websiteURL: TARGET_URL,
    scriptUrl: SCRIPT_URL,
  });

  const headers = { Cookie: `aws-waf-token=${solution.token}` };
  // Replay with the user agent the solve presented.
  if (solution.userAgent) headers['User-Agent'] = solution.userAgent;
  const response = await fetch(TARGET_URL, { headers });
  const text = await response.text();
  if (!response.ok || !text.includes(SUCCESS_TEXT)) {
    throw new Error(`The site did not accept the result (HTTP ${response.status}).`);
  }
  console.log('The site accepted the result.');
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Python

pip install requests and python aws-challenge-python.py after the same constants: the cookie header, the user agent and the response check are the whole replay, so the negative control — the same request without the cookie — still applies.

aws-challenge-python.py
# aws-challenge-python.py - solve AWS Challenge and request the protected page with the
# aws-waf-token cookie.
# Install: pip install requests
# Run: python aws-challenge-python.py

import time

import requests


# Your SurSolver API key.
CLIENT_KEY = "YOUR_API_KEY"
# The protected page that answers with the challenge.
TARGET_URL = "https://example.com/page"
# The challenge.js request of the challenged page (DevTools, Network).
SCRIPT_URL = "https://example.com/challenge.js"
# Text that appears only in a response the site accepted.
SUCCESS_TEXT = "PASTE_ACCEPTED_MARKER"


API = "https://api.sursolver.com"
POLL_ATTEMPTS = 60


def call(path, body):
    # API errors are HTTP 200 with errorId 1; anything else is a transport failure.
    response = requests.post(f"{API}{path}", json=body, timeout=30)
    response.raise_for_status()
    data = response.json()
    if data["errorId"] != 0:
        raise RuntimeError(f'{data["errorCode"]}: {data["errorDescription"]}')
    return data


def solve(task):
    created = call("/createTask", {"clientKey": CLIENT_KEY, "task": task})
    for _ in range(POLL_ATTEMPTS):
        result = call("/getTaskResult", {"clientKey": CLIENT_KEY, "taskId": created["taskId"]})
        if result["status"] == "ready":
            return result["solution"]
        time.sleep(3)
    raise TimeoutError(f'task {created["taskId"]} was not ready in {POLL_ATTEMPTS} polls')


def main():
    solution = solve({
        "type": "AwsChallengeCookie",
        "websiteURL": TARGET_URL,
        "scriptUrl": SCRIPT_URL,
    })

    headers = {}
    # Replay with the user agent the solve presented.
    if solution.get("userAgent"):
        headers["User-Agent"] = solution["userAgent"]
    response = requests.get(
        TARGET_URL,
        headers=headers,
        cookies={"aws-waf-token": solution["token"]},
        timeout=30,
    )
    if not response.ok or SUCCESS_TEXT not in response.text:
        raise SystemExit(f"The site did not accept the result (HTTP {response.status_code}).")
    print("The site accepted the result.")


if __name__ == "__main__":
    main()

When the flow fails

Symptom Likely cause What to check
Could not read scriptUrl from the page; set SCRIPT_URL. The visited URL did not trigger the challenge, so no challenge.js request was made Copy the challenge.js request from the Network panel of the URL that answers with the challenge stub
The stub still answers after the cookie is set Cookie on the wrong domain, or a replay without the solve's user agent Set the cookie on the protected origin and send solution.userAgent as User-Agent
HTTP 405 with x-amzn-waf-action: captcha The route is guarded by the CAPTCHA action, not Challenge This task does not solve CAPTCHA puzzles; confirm which action guards the route before integrating
The page loads but the app request is still refused The application's own authentication, or another WAF rule Read the application response; the token addresses the Challenge action only
createTask rejects scriptUrl An HTTP loader, a URL fragment, or a path not ending in /challenge.js Copy the exact request URL from the challenged page
ERROR_TASK_TIMEOUT or ERROR_FAILED A final solve-side failure Submit a new task; Errors lists the codes

Charging follows the standard lifecycle: charged on acceptance, one automatic refund when a task fails (Task lifecycle).

The AWS Challenge solver guide documents the input constraints and the Challenge/CAPTCHA boundary, and the AWS Challenge API reference shows the exact payload. For the same apply-a-result-to-your-client pattern outside the browser, the Python web scraping post shows solver results applied to HTTP client requests.

More articles