How to Solve an AWS WAF Widget CAPTCHA With Node.js

aws waf captcha in node.js - How to Solve an AWS WAF Widget CAPTCHA With Node.js

To solve an AWS WAF CAPTCHA in Node.js on a widget page, you read the widget’s apiKey and the jsapi.js address from the page, send them to the CapSkip AWS WAF solver with amazonWaf, trade the voucher pair it returns for a token, and send that token with your next request. The widget is the second shape AWS WAF uses. A form or login page draws the puzzle itself, so there is no HTTP 405 page and no gokuProps to read. This guide does the whole loop with the CapSkip Node.js SDK and the fetch that ships with Node.js. The 405 page has its own Python guide.

What you need

  • CapSkip 3.0.0 or later running on a Windows PC. That release added AWS WAF solving. The setup guide covers the first start.
  • Node.js 18 or later, which is what the package asks for, and the capskip package at version 1.4.0 or later. Earlier versions have no amazonWaf method. The code below ran on Node.js 24.22.0 with capskip 1.4.0. No other Node.js version was tried here.
  • A PowerShell prompt. The commands use PowerShell syntax.
  • A page that you own or may automate, with an AWS WAF CAPTCHA widget on it, and a browser with developer tools to look at it once.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a script on another PC, a VPS or a hosted platform can reach it over the API. Both are set under connection settings.

How this was tested: the code ran against three mock servers that I wrote, not against a real AWS WAF site. One copies the AWS WAF checks listed in the CapSkip API docs, one plays a site with a widget on its contact page and one plays AWS’s token host. The SDK was the real capskip 1.4.0. This code did not run against the real CapSkip app, and no live AWS WAF site was involved, so no number below is a solve time, and I cannot tell you that a given live site accepts the token this flow produces.

Step 1: set up the project and find the widget values

Create a folder, initialise a project and install capskip:

npm init -y
npm install capskip

A widget page loads a script called jsapi.js and calls AwsWafCaptcha.renderCaptcha with an options object. According to the AWS WAF documentation, the apiKey in that object is an encrypted key that lists the client domains allowed to use the widget, so it belongs to one site. CapSkip needs two things from the page: that apiKey, which it calls the sitekey, and the full address of jsapi.js. A widget page has no gokuProps, so you send no iv and no context. Sending the jsapi.js address without them is what marks the request as a widget solve.

The first block reads both from the page’s HTML. Some sites keep the apiKey in a separate JavaScript bundle instead. When that happens the function returns null, and the fix is to search the scripts the page loads for apiKey.

'use strict';
const { CapSkip, ApiException } = require('capskip');

const PAGE_URL = 'https://www.example.com/contact'; // the page that draws the widget
// The folder that the page's challenge.js loads from (see the Network tab).
const TOKEN_BASE = 'https://3f2a9c7d41b0.edge.sdk.awswaf.com/3f2a9c7d41b0/8e51d0c7a2f4/';

async function readWidget() {
  const res = await fetch(PAGE_URL, { headers: { Accept: 'text/html' } });
  const html = await res.text();
  const src = /<script[^>]+src="([^"]*\/jsapi\.js)"/i.exec(html);
  const key = /apiKey\s*:\s*["']([^"']+)["']/.exec(html);
  if (!src || !key) return null; // not in the HTML: look in the page's scripts
  return { sitekey: key[1], jsapiScript: new URL(src[1], PAGE_URL).href };
}

The script address on a page can be relative or start with two slashes, so the function turns it into a full address with the page URL. TOKEN_BASE near the top of the block is a constant you fill in by hand, and the next step explains why.

Step 2: find the token host once

The voucher pair is traded for a token on AWS’s own host. On a widget page that host is not in the HTML you fetched. Open the page in a browser, open the Network tab, reload, and look for the request to challenge.js that jsapi.js makes for you. Its folder is the one to copy, ending with a slash. The CapSkip docs say to replace challenge.js with voucher, which is what the code in the next step does with TOKEN_BASE.

This is one lookup per site, so keep it in your configuration. I cannot say how long it stays the same on a real site. If the exchange in the next step starts failing, check the Network tab again before you suspect anything else.

Step 3: solve it and trade the voucher

The SDK method is amazonWaf. Only the sitekey and the page URL are required, and the options carry the jsapi.js address. The script address must be an https address on an awswaf.com host, and the SDK refuses anything else before a request is sent. The answer is an object with captcha_voucher and existing_token, plus the raw answer under code and the task number under captchaId. The method waits with the longer 300 second timeout that reCAPTCHA uses, because CapSkip solves the puzzle in a real browser. I did not time it.

The exchange is a POST to the voucher endpoint with the pair as a JSON body. The CapSkip docs send it as text/plain with an Origin header that matches the site. The CapSkip app’s own code also sends a Referer, so the code sends both. Node.js’s fetch lets you set Origin, which a browser would not. The SDK documentation says the pair is short-lived, so exchange it at once and never store it.

// Local mode. In Server mode, use your server's address instead.
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });

async function getToken(widget) {
  // The answer is a voucher pair, not a token.
  const result = await solver.amazonWaf(widget.sitekey, PAGE_URL, {
    jsapiScript: widget.jsapiScript,
  });
  const reply = await fetch(new URL('voucher', TOKEN_BASE), {
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain;charset=UTF-8',
      Origin: new URL(PAGE_URL).origin,
      Referer: new URL(PAGE_URL).origin + '/',
    },
    body: JSON.stringify({
      captcha_voucher: result.captcha_voucher,
      existing_token: result.existing_token,
    }),
  });
  if (!reply.ok) throw new Error(`voucher exchange failed: HTTP ${reply.status}`);
  return (await reply.json()).token; // the aws-waf-token value
}

To send the solve through a proxy, add a proxy option to the call: an object with type set to HTTP and uri set to user:[email protected]:8080. In the mock run that arrived at in.php as a proxy and a proxytype. One address is fine for testing, but sites behind AWS WAF often pair the CAPTCHA with rate limits, so a steady stream of solves from one address draws harder treatment. The API docs suggest a proxy list in the CapSkip settings, or a per-call proxy when the token must come from a particular network. CapSkip’s own solver sends its page loads, the puzzle and the voucher exchange through one proxy, so AWS sees one address. If you pass a proxy to the solve, send your own requests through the same one. I could not test that part.

Step 4: send the token the way the page does

On a widget page the token is the same one the page’s own onSuccess callback receives. Pages differ in how they pass it on. Many send it as the aws-waf-token cookie and others as an x-aws-waf-token header, so submit the form once by hand with the Network tab open and copy what the page’s request carries. The function below sends the cookie and shows the header as the alternative. The form fields are placeholders for yours.

function submitForm(token) {
  return fetch(PAGE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Cookie: `aws-waf-token=${token}`, // or 'x-aws-waf-token': token
    },
    body: new URLSearchParams({ email: '[email protected]', message: 'Hello' }),
  });
}

Step 5: the full working example

The last block turns the pieces into a full AWS WAF CAPTCHA in Node.js solver, with the retry and the entry point. Save all four code blocks in one file, one after another, and run it with node. A failed puzzle raises ApiException, and the loop tries again, which draws a new puzzle each time. Any other error is thrown, so a wrong host or a refused exchange is not retried silently.

async function main() {
  const widget = await readWidget();
  if (!widget) throw new Error('no jsapi.js script or apiKey in the page HTML');
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const token = await getToken(widget); // exchange at once, never store it
      const res = await submitForm(token);
      console.log(res.status, await res.text());
      return;
    } catch (err) {
      if (!(err instanceof ApiException)) throw err;
      console.log(`attempt ${attempt}: ${err.message}`); // each retry draws a new puzzle
    }
  }
  throw new Error('AWS WAF CAPTCHA not solved in 3 attempts');
}

main().catch((err) => {
  console.error(`${err.constructor.name}: ${err.message}`);
  process.exitCode = 1;
});

In the mock run the script behaved like this:

Mock settingWhat the script did
Every puzzle answeredOne task, one voucher exchange, then status 200 and the text Message sent
First two puzzles refusedTwo attempt lines, then status 200 on the third task
First three puzzles refusedThree attempt lines, then the error line and exit code 1

The token works for as long as the site’s immunity time. AWS sets 300 seconds by default and lets the site owner choose from 60 seconds to three days, as the AWS WAF documentation explains. If you send many requests, keep the token and reuse it until it expires instead of solving once per request.

Common errors

ErrorCauseFix
no jsapi.js script or apiKey in the page HTMLThe page builds the widget from a bundle, so the HTML does not hold themSearch the loaded scripts for apiKey and jsapi.js, then pass the values in by hand
ValidationExceptioniv without context, or a script address that is not a full https address on an awswaf.com hostSend no iv or context for a widget, and copy the jsapi.js address exactly as the page has it
ApiException with ERROR_CAPTCHA_UNSOLVABLEThe puzzle could not be answeredSubmit again. The API docs note that every attempt draws a fresh puzzle
ERROR_TASK_NOT_SUPPORTEDThe API docs list this for a task type that is switched off in CapSkipCheck the AWS WAF settings in the CapSkip app
ERROR_PROXY_FORMATThe proxy string could not be parsedUse user:pass@ip:port
ERROR_NO_SLOT_AVAILABLEThe API key has a request rate limit and you went over itWait a few seconds and submit again
NetworkExceptionCapSkip is not running, or the host or port is wrongStart CapSkip and check the connection settings
voucher exchange failed: HTTP 4xxMost likely a stale or mismatched pair, or the wrong TOKEN_BASE. The CapSkip app’s code expects HTTP 400 from AWS for an invalid existing_token. I could not reproduce this one against real AWSExchange at once, check the folder in the Network tab, and submit a new task
The form post is still refusedThe token expired, or the page passes it in a header and you sent a cookieCopy what the page’s own request carries

Running the solver on a server

The script needs two things from CapSkip: a host and a port. In Local mode that is 127.0.0.1 and your own PC. In Server mode CapSkip listens on your network address or a public IP, so a script on another PC, a VPS or a hosted platform can call it over the same API. Change the host and port in the CapSkip constructor and nothing else in the code changes. A static public IP is recommended for stable remote access. Server mode is still your own hardware and still unmetered. It does open the port to any address, so add a Windows Firewall block rule for the addresses that should not reach it. The connection settings show how to switch modes.

FAQ

How do I tell a widget page from a 405 challenge page?

A challenge page answers the request with HTTP 405 and defines gokuProps. A widget page is an ordinary page that loads jsapi.js and calls renderCaptcha, usually on a form. The same amazonWaf method handles both, with different values.

Can I solve an AWS WAF CAPTCHA in Node.js without a browser?

Your script needs no browser. It sends plain HTTP requests to CapSkip and to the site, and CapSkip solves the puzzle in a real browser on the machine where it runs, as the SDK documentation describes. You only open a browser yourself once, to read the values off the page.

Does the SDK return the token directly?

No. It returns captcha_voucher and existing_token, and you trade them at the voucher endpoint. The JSON createTask API accepts AWS WAF too, and several of its formats return the token directly. The API docs list them.

Can a hosted platform run this against my CapSkip?

Yes, through Server mode. Set CapSkip to listen on your network or public IP, point the CapSkip constructor at that host and port, and the loop works the same as on one PC. The solver stays on your hardware.

The short version

An AWS WAF CAPTCHA in Node.js is the same puzzle on a widget as on a 405 page, in a different place: read the apiKey and the jsapi.js address, call amazonWaf, trade the pair at the voucher endpoint, and send the token the way the page does. CapSkip is a captcha bypass that runs on your own machine, so a puzzle it cannot answer costs you a retry and not a charge. For the rest of the SDK, see the Node.js CAPTCHA solver page.