When I was called in, the attack had been going on for about a week.
It was targeting the login page of a legacy application that generated its HTML server-side.
Requests numbered in the millions and came from a very large number of IP addresses, which made IP-based blocking largely ineffective. Unlike other campaigns I had encountered, the attacker was also rotating JA3 and JA4 fingerprints. Even rate limiting aggregated on those signals could therefore provide only a partial response.
A few days later, a similar attack targeted a second application belonging to my client. This time, it was no longer the legacy server-side application, but a SPA (single-page application) calling a JSON API to create accounts.
These two incidents gave me the opportunity to use both integration modes offered by AWS WAF Challenge:
- the challenge returned directly by WAF for an HTML page;
- the challenge solved beforehand by
challenge.js, then passed to an API called withfetch.
Why Put the Challenge at the Infrastructure Layer?
If my client called me after seven days, it was because the team had first tried to deal with the attack at the application layer by integrating Cloudflare Turnstile.
The integration was solid. A PrestaShop module managed the keys, global activation, and separate configurations for each tenant. On submission, the application retrieved the token from the form and validated it directly with Cloudflare:
$turnstileToken = Tools::getValue('cf-turnstile-response');
if (empty($turnstileToken)) {
$turnstileValid = false;
} elseif (!$this->verifyTurnstileToken($turnstileToken)) {
$turnstileValid = false;
}
Validation then required a server-side call to siteverify:
$response = Tools::file_get_contents(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
false,
stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query([
'secret' => $secretKey,
'response' => $token,
'remoteip' => Tools::getRemoteAddr(),
]),
'timeout' => 5,
],
])
);
This approach worked, but only partially: a significant proportion of the traffic still managed to obtain valid tokens. In particular, we observed tokens generated from a group of Dutch IP addresses and then presented to our infrastructure from Spain. The attacker had therefore industrialized token acquisition and circulation. A token proved that a challenge had been solved, but it was neither a persistent identity nor, in this flow, strictly bound to the IP address that obtained it.
More importantly, this protection had a major drawback: before rejecting a request, the entire chain had to be engaged: the CDN, load balancer, web server, PHP, framework, and our validation code. Even when authentication failed, a significant part of the technical cost had already been incurred. With millions of calls, this affected application performance, although autoscaling limited the impact, as well as the bill.
With a Challenge rule placed in AWS WAF, which the application was already using elsewhere, a request without a valid token is stopped at the edge. It consumes no PHP worker, no database connection, and no application compute capacity. That was exactly what we needed when facing several million requests.
Moving the control also brings a maintenance benefit. Any validation managed by the application necessarily involves code in the form, the controller, secret management, the remote call, and error messages. With a challenge managed by WAF, the backend is not even aware of its existence. It receives only requests that have already passed the check.
The provider of the application-level challenge, Cloudflare versus AWS, is not the point of this comparison. The same distinction would exist with any mechanism validated in the backend. What I want to highlight here, even though I admit I was tired of seeing the developer give in to the “obvious Cloudflare choice” because of its dominant position instead of calling me on the first day of the attack ^^, is the architectural difference between a control managed by the application and one placed at the edge, upstream from the origin.
First Implementation: An Application That Generates Its HTML Server-Side
On the legacy application, authentication uses a native HTML form submitted with POST.
This detail matters. When a user opens an HTML page, the HTTP response becomes a document that the browser must display. AWS WAF can therefore intercept this navigation and respond with its Challenge request. The browser executes the JavaScript provided by AWS WAF, performs the silent proof of work, obtains a token, and the interstitial script then transparently retries the backend request, which WAF now allows through.
In this case, implementing the challenge with AWS WAF is extremely simple.
I started by assigning a label to requests matching the login flow. The challenge rule takes just 15 lines of declarative code, including observability!
rule {
name = "ChallengeLoginNoToken"
priority = 13
action {
challenge {}
}
statement {
label_match_statement {
key = "login"
scope = "LABEL"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "ChallengeLoginNoToken"
sampled_requests_enabled = true
}
}
The login label was assigned by an earlier rule to POST requests targeting /login. This separation between detection and action makes it possible to reuse the same scope for other protections, such as rate limiting by IP address or JA4 fingerprint.
For a server-side site, there is nothing to add to the PHP code. There is no SDK to integrate and no token to validate in the application controller.
This is the simplest configuration: the browser requests a document, WAF can respond with an executable document, and the application is called only after the challenge has been solved.
Implementation 2: AWS WAF Challenge on a SPA
The second application used a SPA, built with React, for its interface and an API hosted on another subdomain.
Account creation conceptually looked like this:
await fetch("https://api.example.com/account-registration", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
If AWS WAF returns its challenge directly to this request, the response arrives inside fetch. The browser does not interpret it as a new document and does not automatically execute the JavaScript contained in the response. The SPA therefore receives a 202 status, potentially with a body it does not know how to process, and the registration flow fails.
This is a property of the browser execution model, not a limitation specific to AWS. JavaScript returned as data by an XHR or fetch request is not executed spontaneously.
The solution is to reverse the order of operations:
- the SPA loads the AWS WAF
challenge.jsSDK; - the SDK silently solves the challenge within the page;
- the SPA retrieves a token;
- it attaches that token to the API request;
- WAF verifies the token before allowing the request through.
Configuring WAF for a SPA
The Web ACL must first know the domains on which its tokens are valid. In my case, the frontend and API used two subdomains of the same root domain:
resource "aws_wafv2_web_acl" "acl_cloudfront" {
provider = aws.us-east-1
name = "cloudfront-api-acl"
scope = "CLOUDFRONT"
token_domains = ["example.com"]
default_action {
allow {}
}
# ...
}
I then assign a label to POST requests targeting the sensitive endpoint and verify the token with a Challenge action:
rule {
name = "ChallengeRegister"
priority = 15
action {
challenge {}
}
statement {
label_match_statement {
key = "register"
scope = "LABEL"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "ChallengeRegister"
sampled_requests_enabled = true
}
}
Up to this point, the implementation is therefore the same as in V1. However, we need to send a valid token “on the first try,” since we cannot process the challenge synchronously.
The JavaScript SDK URL and secret key to inject into the SPA are provided in the integration section of the AWS WAF console. I inject them into the SPA using an environment variable.
A Small Implementation Detail That Can Become a Blocker
The first version of my loader simply waited for the script's load event. That was not enough. The initial jsapi.js script loads challenge.js in turn, then initializes window.AwsWafIntegration asynchronously.
The first script may therefore have finished loading while getToken() is not yet available. The symptom was particularly misleading: the first submission was sent without a token, which was obtained just a few milliseconds later.
I solved this problem with a bounded wait:
function waitForIntegration(timeoutMs = 3000): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (typeof window.AwsWafIntegration?.getToken === "function") {
resolve(true);
return;
}
if (Date.now() - start >= timeoutMs) {
resolve(false);
return;
}
window.setTimeout(tick, 50);
};
tick();
});
}
export async function getWafToken(): Promise<string | undefined> {
await injectWafScript();
if (!(await waitForIntegration())) {
return undefined;
}
return window.AwsWafIntegration!.getToken();
}
The token is then placed in the header specified by AWS:
export async function register(payload: RegisterPayload) {
const wafToken = await getWafToken();
return publicClient.post("/account-registration", payload, {
headers: {
"x-aws-waf-token": wafToken,
},
});
}
A Second Detail That Can Become a Blocker
In an architecture involving multiple subdomains, CORS must be taken into account, particularly the OPTIONS preflight request, since adding the token makes the request “complex.”
The header must be permitted by the response to the OPTIONS request:
Access-Control-Allow-Headers:
Authorization,
Content-Type,
x-aws-waf-token
This therefore requires a small addition to the CloudFront configuration.
How Much Does This Protection Cost?
Based on the public AWS pricing available in August 2026, the AWS WAF base service is billed at:
- $5 per Web ACL per month;
- $1 per rule per month;
- $0.60 per million requests processed, within the standard WCU allocation.
The Allow, Block, and Count actions do not add a per-action charge. Challenge responses are billed separately at $0.40 per million Challenge responses.
This additional cost remains low, especially if the rule is limited to a few high-value entry points: login, registration, password recovery, order validation, or a sensitive business operation.
The difference compared with the specialized Fraud Control rules is considerable. AWS WAF Account Takeover Prevention and Account Creation Fraud Prevention add a monthly subscription, followed by per-request charges with declining but still high rates. In AWS's public example, 15 million requests analyzed by ATP generate more than $8,000 in Fraud Control charges.
ATP provides much more advanced capabilities. It simply does not address the same economic need. When a targeted Challenge action, rate limiting, and a few network signals are enough, the cost-effectiveness is hard to beat.
In Summary: One Solution, Two Implementations
The first implementation takes 15 lines of Terraform because the browser navigates to an HTML document. AWS WAF can respond directly with the challenge JavaScript.
The second requires a small frontend service because the protected call is made with fetch. The challenge must be executed before the request, and its token must then be passed in a header or cookie.
In both cases, the backend implements no challenge validation. It stores no additional secret key, calls no external service, and consumes no resources for rejected requests.
AWS WAF Challenge is not a replacement for a complete anti-abuse strategy. It does, however, make it possible to move an expensive operation to the right place: before the application.
Top comments (0)