How to Pass an AWS WAF CAPTCHA in C# With HttpClient

To pass an AWS WAF CAPTCHA in C#, you fetch the protected page with HttpClient, pull five values out of its HTTP 405 answer, hand them to AmazonWafAsync in the CapSkip AWS WAF solver, and exchange the voucher pair it returns for an aws-waf-token cookie that your CookieContainer sends from then on. CapSkip solves on your own Windows PC, so there is no per-solve fee and a failed attempt costs nothing extra. The code is five short blocks and about 130 lines.
What you need
- CapSkip 3.0.0 or later, installed on Windows. AWS WAF support starts with that release.
- The .NET 8 SDK or newer, plus the CapSkip NuGet package at 1.4.0 or newer. Version 1.3.0 has no AmazonWafAsync, which I checked in the package. Everything below ran on .NET SDK 10.0.100, on net10.0 and net8.0, with CapSkip 1.5.0.
- A page of your own, or one you may automate, protected by a rule with the AWS WAF CAPTCHA action. This AWS WAF CAPTCHA in C# guide handles the challenge page that answers HTTP 405. The Python guide explains that page in more detail and links to the widget version.
- An address for the solver. Local mode is 127.0.0.1 on the same PC. Server mode lets a program on another machine call CapSkip over the API, as the section near the end explains. You switch between them under connection settings.
How this was tested: against three mock servers that I wrote, with the real CapSkip 1.5.0 package. One mock applies the checks from the AWS WAF section of the CapSkip API docs, one is the protected site and one is the token host. A small test-only handler redirected the awswaf.com hosts to that token host, and it is not in the code below. The CapSkip app was running on my PC and I sent it only invalid requests, so I started no puzzle and saw no real solve. No live AWS WAF site was involved. There is no solve time in this post, and I can’t promise that a given live site accepts the cookie this flow produces.
Step 1: create the project and read the 405 page
Create a console project and add the package. The default template turns on implicit usings and nullable types, and the code relies on both.
dotnet new console -o aws-waf-demo cd aws-waf-demo dotnet add package CapSkip
A protected page answers with HTTP 405 and the header x-amzn-waf-action set to captcha. The body defines window.gokuProps and loads challenge.js and captcha.js from an awswaf.com host. The code needs five values from it: gokuProps.key, iv and context, plus the addresses of both scripts. Both iv and context are new on every response, so never reuse an old pair.
Replace the contents of Program.cs with the C# blocks of Steps 1 to 4, one after another, starting with this one. The short snippet in Step 2 is not one of them. Page.Read matches with two regular expressions, since the object sits inside a script tag, not in a JSON file. It returns null when the response is not a parseable CAPTCHA page. That covers a plain 200, a 405 with an empty body, and a 405 page that lacks one of the five values.
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using CapSkip;
record Challenge(string SiteKey, string Iv, string Context, string ChallengeScript, string CaptchaScript);
static class Page
{
// Read the values from an AWS WAF CAPTCHA page (HTTP 405), or return null.
public static Challenge? Read(HttpStatusCode status, string html)
{
if (status != HttpStatusCode.MethodNotAllowed || !html.Contains("gokuProps")) return null;
var block = Regex.Match(html, @"gokuProps\s*=\s*\{(.*?)\}", RegexOptions.Singleline).Groups[1].Value;
string Field(string name) =>
Regex.Match(block, "\"" + name + "\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value;
string Script(string name) =>
Regex.Match(html, "<script[^>]+src=\"([^\"]+/" + name + "\\.js)\"").Groups[1].Value;
var found = new Challenge(Field("key"), Field("iv"), Field("context"),
Script("challenge"), Script("captcha"));
var values = new[] { found.SiteKey, found.Iv, found.Context, found.ChallengeScript, found.CaptchaScript };
return values.Any(string.IsNullOrEmpty) ? null : found;
}
}The second block is the HttpClient side. AWS documents that the puzzle page is included when the request carries an Accept header with text/html (see the AWS WAF documentation). HttpClient sends no Accept header unless you add one, so Web.Get sets it. Without it, my mock sends an empty 405. I did not test what real AWS sends.
static partial class Web
{
const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
// The jar lives in the handler, so a cookie you add later goes out with every request to its host.
public static HttpClient Client(CookieContainer jar) =>
new(new HttpClientHandler { CookieContainer = jar }) { Timeout = TimeSpan.FromSeconds(30) };
public static async Task<(HttpStatusCode Status, string Body)> Get(HttpClient http, string url)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html"); // HttpClient sends none
using var response = await http.SendAsync(request);
return (response.StatusCode, await response.Content.ReadAsStringAsync());
}
}Step 2: solve it with AmazonWafAsync
The signature is AmazonWafAsync(sitekey, url, options, cancellationToken). Only sitekey and url are required, and a call with no options worked in the mock run. The API docs say that any value you leave out is read from the page at solve time. This code sends all five anyway, because it fetched the page a moment ago and Step 3 needs the challenge.js address regardless. The values iv and context go together. Sending one without the other throws a ValidationException before any request. The same happens for a script address that is not https on an awswaf.com host ending in challenge.js, captcha.js or jsapi.js. I tried another host, an http address and a wrong file name, and all three were refused.
The result is a SolveResult. CaptchaVoucher and ExistingToken hold the pair, Code holds the raw answer as a JSON string, and CaptchaId holds the task number. The call waits up to the 300 second recaptchaTimeout default, because CapSkip opens a real browser to mint a token before it answers the puzzle. I did not time a solve.
static class Solve
{
// Local mode. In Server mode, use your server's address instead.
static readonly CapSkipClient Solver = new(host: "127.0.0.1", port: 8080);
public static Task<SolveResult> Run(Challenge c, string pageUrl, CancellationToken ct) =>
Solver.AmazonWafAsync(c.SiteKey, pageUrl, new Dictionary<string, object?>
{
["iv"] = c.Iv,
["context"] = c.Context,
["challenge_script"] = c.ChallengeScript,
["captcha_script"] = c.CaptchaScript,
}, ct);
}To route the solve through a proxy, add a proxy entry to the options dictionary. Your own requests should leave from the same address, so give your handler the same proxy. WebProxy does not read a user:pass@ string, so the login goes into Credentials. CapSkip’s own code lifts it out the same way. The first line below reached in.php as a proxy and a proxytype in the mock run. The second replaces the handler inside Web.Client. It compiled, but I did not send a request through a proxy, so treat it as untested.
// Snippet, not part of Program.cs. The first line is checked at in.php, the second is not tested.
["proxy"] = new Proxy("HTTP", "user:[email protected]:8080"), // add to the options in Solve.Run
var handler = new HttpClientHandler { UseProxy = true, CookieContainer = jar, Proxy =
new WebProxy("http://1.2.3.4:8080") { Credentials = new NetworkCredential("user", "pass") } };Step 3: exchange the voucher with HttpClient
In this flow your code makes the voucher exchange, not CapSkip, so your HttpClient is the address AWS sees on that leg. The voucher endpoint sits beside challenge.js: the same folder, with voucher in place of challenge.js. Web.Exchange POSTs the pair as JSON and reads the token field of the reply. The API docs example sends a text/plain;charset=UTF-8 content type and an Origin header. I read the CapSkip app’s own exchange code, which also adds a Referer and the User-Agent of the browser that minted the token, and counts HTTP 400 for a null or invalid existing_token as a failed exchange. Web.Exchange sends all four headers. The in.php answer does not return that User-Agent, so this code sends a generic Chrome one, and I could not test whether AWS minds the difference. The SDK documentation says to exchange the pair as soon as it arrives. It gives no lifetime, and I did not measure one.
The content type needs care in C#. StringContent with text/plain and Encoding.UTF8 writes it as text/plain; charset=utf-8, with a space after the semicolon and a lowercase utf-8, which I measured. So the method builds a ByteArrayContent and sets the header by hand. The mock recorded the exact documented spelling.
static partial class Web
{
// Trade the voucher pair for the aws-waf-token. Your code makes this call, not CapSkip.
public static async Task<string> Exchange(HttpClient http, SolveResult result, Challenge c, string pageUrl)
{
// The voucher endpoint sits beside challenge.js, same folder.
var voucherUrl = c.ChallengeScript[..c.ChallengeScript.LastIndexOf('/')] + "/voucher";
var origin = new Uri(pageUrl).GetLeftPart(UriPartial.Authority);
var pair = JsonSerializer.Serialize(new
{
captcha_voucher = result.CaptchaVoucher,
existing_token = result.ExistingToken,
});
using var request = new HttpRequestMessage(HttpMethod.Post, voucherUrl)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes(pair)),
};
// StringContent would rewrite this header, so write it exactly.
request.Content.Headers.TryAddWithoutValidation("Content-Type", "text/plain;charset=UTF-8");
request.Headers.TryAddWithoutValidation("Origin", origin);
request.Headers.TryAddWithoutValidation("Referer", origin + "/");
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("token").GetString()!; // the aws-waf-token cookie value
}
}Step 4: the full working example
Change PageUrl to your own protected page first. As pasted it requests example.com, which has no CAPTCHA rule, so the program finds nothing to solve. Then run it with dotnet run. Every attempt fetches the page again, which gives each try its own iv and context. The ApiException message contains the error code, so the catch filter retries only ERROR_CAPTCHA_UNSOLVABLE. Any other code means a retry would fail the same way, so it surfaces. If you changed the replacement error text in CapSkip, the filter will not match it. The token goes into the jar for the host of PageUrl, and the last request reads the page again. That is the whole AWS WAF CAPTCHA in C# loop.
static class Program
{
const string PageUrl = "https://www.example.com/"; // change this to your protected page
static async Task<int> Main()
{
var jar = new CookieContainer();
using var http = Web.Client(jar);
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); // a budget for all solves
for (var attempt = 1; attempt <= 3; attempt++)
{
var (status, html) = await Web.Get(http, PageUrl); // fresh page, fresh iv and context
var challenge = Page.Read(status, html);
if (challenge is null)
{
Console.WriteLine($"{(int)status} {html[..Math.Min(60, html.Length)]}");
return status == HttpStatusCode.OK ? 0 : 1; // 200 means there was nothing to solve
}
SolveResult result;
try { result = await Solve.Run(challenge, PageUrl, cts.Token); }
catch (ApiException ex) when (ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
{
Console.WriteLine($"attempt {attempt}: {ex.Message}"); // the message holds the error code
continue; // a fresh page draws a fresh puzzle
}
catch (CapSkip.TimeoutException)
{
Console.WriteLine("CapSkip gave no answer in time");
return 1;
}
var token = await Web.Exchange(http, result, challenge, PageUrl); // never store the pair
jar.Add(new Uri(PageUrl), new Cookie("aws-waf-token", token));
var (code, page) = await Web.Get(http, PageUrl);
Console.WriteLine($"{(int)code} {page[..Math.Min(60, page.Length)]}");
return code == HttpStatusCode.OK ? 0 : 1;
}
Console.WriteLine("AWS WAF CAPTCHA not solved in 3 attempts");
return 1;
}
}In the mock run the program behaved like this. Every row ran on both net10.0 and net8.0 with the same output.
| Mock setting | What the program did |
|---|---|
| Mock answers every puzzle | A single task and a single exchange, then 200 and the real page |
| Mock refuses the first two puzzles | Two attempt lines, then 200 after the third task |
| Mock refuses the first three puzzles | Three attempt lines, the message that the CAPTCHA was not solved, exit code 1, and no voucher request |
| Accept line removed from Web.Get | The mock sent an empty 405, Page.Read returned null, and the program printed 405 and exited with 1 |
| Mock answers ERROR_BAD_PARAMETERS | The ApiException surfaced on the first attempt, unhandled, with no retry |
| Voucher pair altered before the exchange | The mock answered 400 and the program ended with an unhandled HttpRequestException |
The cookie works for the site’s CAPTCHA immunity time, the period AWS WAF lets a solved client skip the puzzle. The AWS WAF immunity time documentation gives 300 seconds as the default and lets the site owner set it from 60 seconds to three days. Keep one HttpClient and its jar for the whole run, and reuse the token until the 405 comes back.
Common errors
| What you see | Cause | Fix |
|---|---|---|
| CapSkip.ValidationException | Thrown by the SDK before any request: iv sent without context, a script address outside https and awswaf.com, a url that is not http or https, or an empty sitekey. The message names the culprit | Fix the argument it names |
| ApiException: ERROR_CAPTCHA_UNSOLVABLE | CapSkip could not answer that puzzle. The API docs say each new attempt gets a different puzzle | The loop retries it with a new page |
| ApiException with another code | ERROR_BAD_PARAMETERS, ERROR_PROXY_FORMAT, ERROR_TASK_NOT_SUPPORTED (the API docs say the task type is not supported or its setting is turned off in CapSkip) or ERROR_NO_SLOT_AVAILABLE (a key over the request rate limit set in CapSkip, which applies only to keys that have one) | It surfaces without a retry. Read the message. For the rate limit, wait a few seconds and run again |
| CapSkip.TimeoutException | No answer within the wait. It derives from CapSkipError, not from ApiException, so the retry filter does not catch it | Check that CapSkip is running and solving, then run again |
| CapSkip.NetworkException | CapSkip is not running, the host or port is wrong, or your cancellation token fired | Start CapSkip, check the connection settings, and read the message |
| HttpRequestException from Web.Exchange | EnsureSuccessStatusCode saw a non-success reply. A mismatched or altered pair gave 400 in the mock. I could not reproduce a real one, and a pair exchanged too late might fail the same way | Exchange immediately and submit a new task |
| Compiler error CS0104 | A bare TimeoutException is ambiguous between System and CapSkip | Write CapSkip.TimeoutException |
| 405 even with the cookie | The token expired, the cookie was added for another host, or the site reads the token from a header | Solve again, then compare with what the page’s own requests send |
Running CapSkip on a server
CapSkipClient takes a host and a port, plus an apiKey if you turned on API Key Validation, so moving the solver is a one-line change: pass the other machine’s address instead of 127.0.0.1. Server mode makes CapSkip listen on your network address or a public IP, which lets a .NET program on another PC, a VPS or a hosted platform call the same API. The machine running CapSkip must be Windows, a PC or a Windows server, and it stays yours and unmetered. CapSkip recommends a static public IP so remote access stays stable. The port then accepts connections from any address, so add a Windows Firewall block rule for the ones that should not get in. The solve then runs on that machine, while the voucher exchange in Step 3 runs wherever your program runs. I did not test whether AWS minds that difference. If it does, pass the same proxy to both legs.
FAQ
Why does catch (TimeoutException) not compile with the CapSkip package?
Both System and CapSkip define a TimeoutException. With implicit usings on and using CapSkip at the top of the file, the short name is ambiguous and the compiler stops with error CS0104. Write CapSkip.TimeoutException, or catch CapSkipError, the base class of everything the SDK throws.
Why not use StringContent for the voucher POST?
It rewrites the content type. StringContent with text/plain and Encoding.UTF8 sends text/plain; charset=utf-8, while the API docs show text/plain;charset=UTF-8. A ByteArrayContent with the header written by hand sends the documented spelling. I could not check whether real AWS treats the two differently, so I copied the documented one.
Does the cookie work on other hosts or subdomains?
Not the way the example adds it. A cookie added to the CookieContainer for the PageUrl address is host-only: in a probe it went to www.example.com and not to api.example.com or example.com. If your site calls another host, add the cookie for that host too. The API docs also say some pages pass the token in an x-aws-waf-token request header instead of a cookie, so check what the page’s own requests send.
How do I cancel a solve that takes too long?
Pass a CancellationToken to AmazonWafAsync, as the example does with a ten minute source. A token that is already cancelled stops the call before anything is submitted, but it surfaces as CapSkip.NetworkException with the message A task was canceled, not as OperationCanceledException. The same type reports that CapSkip is not running, so read the message.
An AWS WAF CAPTCHA in C# costs nothing to retry because the solver runs on your own PC, which is why a local captcha bypass suits sites that hand you a new puzzle on every attempt. The same flow in Python is in the Python guide, and the rest of the SDK is on the C# CAPTCHA solver page.
