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 theaws-waf-tokencookie 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 - 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:
- The page is opened once while the script records requests, and the
challenge.jsURL is taken from what the page actually loaded. - An
AwsChallengeCookietask is created withwebsiteURL(the protected URL) andscriptUrl. - Polling continues every three seconds until the status is
ready. A terminalERROR_FAILEDorERROR_TASK_TIMEOUTfails loudly instead of falling through to the replay. - The replay opens a fresh context whose user agent is the solve's
userAgent, and adds the token as theaws-waf-tokencookie on the page's hostname (secure, path/). - 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 - 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 - 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.