How to Solve AWS WAF CAPTCHA in PHP With a cURL Cookie File

To solve an AWS WAF CAPTCHA in PHP, fetch the page with cURL, pull five values out of the HTTP 405 reply, hand them to amazonWaf in the CapSkip AWS WAF solver, swap the voucher pair you get back for an aws-waf-token, and write that token to a cookie file. A PHP process forgets everything when it exits, so the file is where the token lives. CapSkip runs on your own Windows PC and charges no per-solve fee.
What you need
- A Windows PC with CapSkip 3.0.0 or newer, the version that introduced AWS WAF support.
- PHP 8.0 or newer with the curl extension, Composer, and capskip/capskip 1.4.0 or newer, where amazonWaf first appears. Everything below ran on PHP 8.0.30, 8.1.33, 8.2.29, 8.3.28, 8.4.15 and 8.5.0 (WampServer builds for Windows) with capskip 1.6.0, and on 8.0.30 and 8.5.0 with 1.5.0 as well. Version 1.6.0 added hcaptcha and left amazonWaf and the HTTP layer as they were.
- A CA bundle. On all six builds, with curl.cainfo unset, an https request to a public site failed with error 60, SSL certificate problem: unable to get local issuer certificate. Name a CA file in curl.cainfo in php.ini before you start.
- A PowerShell prompt, and a page you own or may automate behind an AWS WAF rule with the CAPTCHA action.
- An address for CapSkip. Local mode serves 127.0.0.1 on the PC running it. Server mode opens the API to your network or public IP, so PHP on another machine can call it. Pick one under connection settings.
How I tested: a mock that I wrote, and no real AWS WAF site. It answers as CapSkip and as the protected page, applying the checks listed in the AWS WAF section of the API docs. A second listener answers as the token host of AWS over TLS, reached through the https_proxy variable, with a throwaway certificate authority in curl.cainfo so certificate checking stayed on. Nothing here is a solve time, and every rule the mock enforces is mine, not documented AWS behaviour.
Step 1: install the SDK and read the 405 page
Make a folder and install the SDK:
mkdir waf-demo cd waf-demo composer require capskip/capskip
Next create waf.php. Steps 1 to 4 all land in that one file, in order. Set PAGE_URL to your own page first, since example.com has no CAPTCHA rule.
Each request gets a fresh cURL handle, freed when the function returns. Page fetches set CURLOPT_COOKIEFILE, so cURL sends whatever token the jar holds, and they set no CURLOPT_COOKIEJAR on purpose (see the FAQ). The AWS CAPTCHA action documentation says the puzzle page is included when the request has an Accept header with the value text/html, so the helper sends exactly that. cURL sends */* by default, and whether AWS counts */* as text/html I could not test. My mock sent an empty 405.
<?php
// waf.php: Steps 1 to 4 go into this one file, in order.
require __DIR__ . '/vendor/autoload.php';
const PAGE_URL = 'https://www.example.com/'; // the page that answers HTTP 405
const JAR = __DIR__ . '/waf-cookies.txt'; // the token store, shared by every PHP process
// One cURL handle per request, freed when the function returns.
function request(string $url, array $options = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, $options + [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36',
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException(curl_error($ch));
}
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}
// A page fetch only reads the jar, so it sends whatever token is in the file.
function fetch(string $url): array
{
return request($url, [
CURLOPT_COOKIEFILE => JAR,
CURLOPT_HTTPHEADER => ['Accept: text/html'], // cURL sends */* unless told otherwise
]);
}A matching CAPTCHA rule makes AWS WAF reply 405 with the header x-amzn-waf-action set to captcha. The body sets window.gokuProps to an object with the entries key, iv and context, and loads challenge.js and captcha.js from an awswaf.com host. The SDK example says gokuProps.iv and gokuProps.context go stale within minutes, so the loop fetches the page again before each solve. The function returns null for any other response.
// The five values on an AWS WAF CAPTCHA page, or null for any other response.
function read_challenge(int $status, string $html): ?array
{
if ($status !== 405 || !str_contains($html, 'gokuProps')) {
return null;
}
preg_match('/gokuProps\s*=\s*\{(.*?)\}/s', $html, $block);
$field = fn (string $name): ?string =>
preg_match('/"' . $name . '"\s*:\s*"([^"]+)"/', $block[1] ?? '', $m) ? $m[1] : null;
$script = fn (string $name): ?string =>
preg_match('/<script[^>]+src="([^"]+\/' . $name . '\.js)"/', $html, $m) ? $m[1] : null;
$found = [
'sitekey' => $field('key'),
'iv' => $field('iv'),
'context' => $field('context'),
'challenge_script' => $script('challenge'),
'captcha_script' => $script('captcha'),
];
return in_array(null, $found, true) ? null : $found;
}Step 2: call amazonWaf
The signature is amazonWaf(string $sitekey, string $url, array $options = []). The sitekey is gokuProps.key on a challenge page. The values from gokuProps.iv and gokuProps.context go in together or not at all, and when both are missing CapSkip reads them from the page when the solve starts. The SDK validates every argument before a request leaves PHP, so a bad value fails at once and the mock never sees it. The errors table lists the cases I tried.
A successful call returns an array with captchaId, captcha_voucher and existing_token, plus the raw answer under the result’s code entry. It is not a token: the voucher pair has to be traded first. The default wait is 300 seconds, since CapSkip solves in a real browser. All SDK exceptions extend CapSkipError, and ApiException carries the error text from CapSkip. A proxy goes in as a proxy option, an array with type HTTP and uri user:[email protected]:8080, which reached the mock as a proxy and a proxytype. Route your own requests through the same proxy, which I could not test.
use CapSkip\CapSkip;
// Local mode. In Server mode, use your server's address instead.
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
function solve(CapSkip $solver, array $challenge): array
{
// The answer is a voucher pair, not a token.
return $solver->amazonWaf($challenge['sitekey'], PAGE_URL, [
'iv' => $challenge['iv'],
'context' => $challenge['context'],
'challenge_script' => $challenge['challenge_script'],
'captcha_script' => $challenge['captcha_script'],
]);
}Step 3: swap the voucher for the token and save it
Take the folder challenge.js was loaded from and add voucher. That is the endpoint, and this leg is your own request, so AWS sees your PHP address on it. Send the pair as a JSON body and read the reply’s token field. The CapSkip docs use a text/plain body plus an Origin header, and the CapSkip app adds a Referer, so the function sends all three. For a string body cURL declares a form post unless told otherwise, which the mock confirmed when I deleted the header line. The SDK documentation calls the pair short-lived, so exchange it immediately.
The call also follows redirects, because the source of the CapSkip app notes that a regional token host of the form id.region.sdk.awswaf.com redirects to the hashed host. My mock sent a 307 for such a host and the call followed it. Without the option it failed with HTTP 307. I tried no other codes.
// Trade the voucher pair for the aws-waf-token value.
function exchange(array $result, array $challenge): string
{
// The voucher endpoint sits beside challenge.js, same folder.
$script = $challenge['challenge_script'];
$voucherUrl = substr($script, 0, strrpos($script, '/')) . '/voucher';
$site = parse_url(PAGE_URL);
$origin = $site['scheme'] . '://' . $site['host'] . (isset($site['port']) ? ':' . $site['port'] : '');
[$status, $body] = request($voucherUrl, [
CURLOPT_POST => true,
CURLOPT_FOLLOWLOCATION => true, // a regional host may redirect to the real one
CURLOPT_POSTFIELDS => json_encode([
'captcha_voucher' => $result['captcha_voucher'],
'existing_token' => $result['existing_token'],
]),
CURLOPT_HTTPHEADER => [
'Content-Type: text/plain;charset=UTF-8', // PHP would send a form type otherwise
'Origin: ' . $origin,
'Referer: ' . $origin . '/',
],
]);
if ($status !== 200) {
throw new RuntimeException("voucher exchange failed: HTTP $status");
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR)['token'];
}The jar is a Netscape-format text file: one cookie per line, fields separated by tabs. The save function is the only code that writes it. It keeps every other line, replaces the old token and writes under an exclusive lock. The line is host-only, so cURL sends the cookie to the host in PAGE_URL and no other. In a probe with www and api names pointed at one machine, a host-only line reached www but not api, while a line with a leading dot and the subdomain field set to TRUE reached both. Expiry is 0 because I do not know the immunity time of the site.
A reader can catch the file mid-write. With one process saving 400 times and another sending 400 requests, 2 requests on 8.4.15 and 1 on 8.0.30 went out without the cookie. Writing a temp file and renaming it did not help on Windows: 5 and 4 requests still lacked the cookie, and the rename itself failed 1 and 7 times in 400. Treat a lone 405 as a reason to retry.
// The only writer of the jar. It keeps every other line and replaces the old token.
function save_token(string $token): void
{
$lines = is_file(JAR) ? file(JAR, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
$lines = array_filter($lines, fn (string $line) => !str_contains($line, "\taws-waf-token\t"));
// Netscape format: host, subdomains, path, secure, expiry (0 = session), name, value
$lines[] = parse_url(PAGE_URL, PHP_URL_HOST) . "\tFALSE\t/\tFALSE\t0\taws-waf-token\t" . $token;
file_put_contents(JAR, implode("\n", $lines) . "\n", LOCK_EX);
}Step 4: put it together behind a lock
The final block is the whole flow. Run it with php waf.php. Its first fetch sends the token from the jar, so a valid token means CapSkip is never contacted. Only a 405 enters the solve path, and that path begins with a lock, the part that only makes sense in PHP. Two scripts started by a scheduler share the jar and share no memory, so both would see the 405 and both would solve. With flock the second one waits, fetches again, finds the first one’s token and returns. PHP releases the lock when the handle is freed as the function ends, and one run makes at most three attempts.
use CapSkip\Exceptions\ApiException;
function fetch_protected(CapSkip $solver): array
{
[$status, $html] = fetch(PAGE_URL); // sends the token in the jar, if there is one
if (read_challenge($status, $html) === null) {
return [$status, $html];
}
$lock = fopen(JAR . '.lock', 'c');
flock($lock, LOCK_EX); // one solve at a time across processes, released when $lock is freed
for ($attempt = 1; ; $attempt++) {
[$status, $html] = fetch(PAGE_URL); // the process we waited for may have saved a token
$challenge = read_challenge($status, $html);
if ($challenge === null) {
return [$status, $html];
}
if ($attempt > 3) {
throw new RuntimeException('AWS WAF CAPTCHA not solved in 3 attempts');
}
try {
save_token(exchange(solve($solver, $challenge), $challenge)); // exchange at once
} catch (ApiException $e) {
echo "attempt $attempt: {$e->getMessage()}\n"; // each retry draws a new puzzle
}
}
}
[$status, $body] = fetch_protected($solver);
echo $status, ' ', substr($body, 0, 60), "\n";Results of the mock run, identical on all six PHP versions:
| Mock setting | What the script did |
|---|---|
| Empty jar, every puzzle answered | Exactly one task and one exchange, ending in 200 |
| Second run, same jar | 200 without a task, because the first request already carried the cookie |
| First two puzzles refused | Two retry messages, success on task three |
| First three puzzles refused | Three retry messages, an uncaught RuntimeException, exit code 255, no voucher request |
| Two scripts started together, puzzle held 2 seconds | With the lock, one task and both ended with 200. Without the flock line, two tasks or more |
Repeated 10 times per variant on 8.0.30 and 8.4.15, the lock gave one task in all 20 runs. Without it, 19 of 20 runs gave two tasks and one run on 8.4.15 gave three. The token stays good until the immunity time of the site runs out, which the CapSkip API docs put at five minutes by default. A run inside that window sends the cookie and skips CapSkip, so a scheduled job can call this script every few minutes.
Do not solve inside a web request
A solve can outlast a PHP web request. The API docs say most tasks are ready within 15 seconds and a slow proxy adds to that, while the PHP manual gives max_execution_time a default of 30 seconds for web use and none for the command line. I ran the whole script under php-cgi.exe, a web SAPI, on 8.0.30 and 8.4.15, against a mock that held each puzzle for 40 simulated seconds. It died with the fatal error Maximum execution time of 30 seconds exceeded after about 33 seconds, because PHP raised the error only when a poll sleep inside the SDK ended. Under php.exe the same script took about 43 seconds.
A shorter SDK timeout does not rescue it. With a timeout of 20 the SDK polls at about 0.25, 0.75, 1.75, 3.75, 7.75, 12.75 and 17.75 seconds, then sleeps until 22.75 and gives up. On 8.0.30 and 8.4.15 a puzzle ready after 16 or 17.5 seconds was collected by about 18 seconds, but one ready after 19 seconds was missed: the call ended in a TimeoutException at about 23 seconds and never read the answer. With the default 5 second polling ceiling, a timeout of 20 only catches answers ready by about 17.75 seconds.
Keep the web side read-only and leave the solving to php waf.php on a schedule. The block replaces the last two lines of a web copy of the script. In a php-cgi test on 8.4.15 with a 40 second mock puzzle, two web requests sent while the job was solving got a 503 with a Retry-After header within 0.25 seconds. The job finished after about 43 seconds with one task and one exchange, and the next web request received the real page.
// Web copy of waf.php: replace its last two lines with this read-only check.
// Solving stays with "php waf.php", run from a scheduled task.
[$status, $html] = fetch(PAGE_URL);
if (read_challenge($status, $html) !== null) {
http_response_code(503);
header('Retry-After: 60');
exit('No valid token yet, try again soon.');
}
http_response_code($status);
echo substr($html, 0, 60);Common errors
| What you see | Cause | Fix |
|---|---|---|
| ValidationException | Raised inside the SDK before any request: gokuProps.iv or gokuProps.context sent alone, a script address that is not https on an awswaf.com host, a script file with the wrong name, a relative page URL or an unknown option | Correct whatever the message names |
| ApiException with ERROR_CAPTCHA_UNSOLVABLE in the message | That puzzle was not answerable. Each new task gets a different one, according to the API docs | Nothing: the loop fetches a new page and submits again |
| NetworkException | Nothing is listening at the host and port. A closed local port failed after about 2 seconds | Launch CapSkip and recheck the host and port |
| TimeoutException | The wait ended with no answer. It extends CapSkipError rather than ApiException, so the retry loop lets it through | Confirm CapSkip is solving, or catch it where you want to give up |
| ApiException uncaught although you catch it | After a use line for CapSkip\CapSkip, a catch written as CapSkip\Exceptions\ApiException names a class that does not exist | Import the exception, or begin the name with a backslash |
| RuntimeException: SSL certificate problem: unable to get local issuer certificate | curl.cainfo is unset, so PHP cannot verify the certificate on the first https request, whether the page fetch or the voucher call | Point curl.cainfo in php.ini at a CA file |
| voucher exchange failed: HTTP 400 | The source of the CapSkip app treats a 400 from the voucher endpoint as a null or invalid existing_token, the likely real cause. My mock returns 400 for a pair it never issued, and I could not reproduce a real refusal | Redo the round with a new task and exchange straight away |
| Deprecated lines naming curl_close on PHP 8.5 | The Http.php file inside the SDK still calls curl_close, which PHP 8.5.0 deprecates. It prints one line per request to CapSkip, and the solve still works | Leave E_DEPRECATED out of error_reporting until the SDK drops the call |
FAQ
Why not set CURLOPT_COOKIEJAR on every page fetch?
A handle writes its jar when PHP frees it, and a handle that read the file before the token was saved writes its old contents back. In a probe the token disappeared from the file the moment such a handle was freed, on all six versions. If your site sets cookies you must keep, give them a separate jar.
How does a widget page differ from this?
A widget page is an ordinary form that loads jsapi.js and draws the puzzle itself. The SDK takes the apiKey of the widget as the sitekey and the jsapi.js address as the jsapiScript option, with no iv or context. This post tests only the 405 page, so the reader in Step 1 would need rewriting for a widget.
Can PHP on another machine use my CapSkip in Server mode?
Yes. In Server mode CapSkip listens on your network or public IP, so PHP on a second PC, a VPS or a hosted platform calls the same API with a different host and port in the constructor, and nothing else changes. A static public IP keeps remote access stable. The hardware stays yours and unmetered. Server mode opens the CapSkip port to every address, so a Windows Firewall block rule for the machines you do not expect is what keeps it private. The voucher exchange runs where PHP runs, not on the CapSkip machine, and I did not test whether AWS cares.
An AWS WAF CAPTCHA in PHP comes down to a cookie file that holds the token and a lock that lets one process solve at a time. Because CapSkip is a captcha solver on your own machine, an unanswerable puzzle costs a retry and no money. The Python version of this flow is in the Python guide, and the wider SDK is covered on the PHP CAPTCHA solver page.
