DEV Community

Cover image for Building a False-Positive-Resilient Challenge Architecture with AWS WAF Dynamic Label Interpolation

Building a False-Positive-Resilient Challenge Architecture with AWS WAF Dynamic Label Interpolation

In May 2026, AWS WAF introduced a feature called dynamic label interpolation. Did you catch the announcement?

It may look like a small addition at first, but I think it offers an elegant way to address one of the hardest parts of operating bot defenses: dealing with false positives.

In this article, I’ll show how to use dynamic label interpolation to build a challenge architecture that gives legitimate users caught by a false positive a self-service way to report the issue and regain access.

Anyone who operates bot protection with a WAF has probably wrestled with false positives. Legitimate users can occasionally be misclassified as bots, which often leads teams to avoid enabling blocking altogether.

The architecture described in this article takes a different approach: instead of treating a bot-detection signal as a final verdict, it redirects suspicious clients to a challenge and allows legitimate users to recover on their own. In practice, this can virtually eliminate false-positive blocking caused by bot detection, even when the underlying detection is not perfect. This design extends the architecture from my previous article, in which clients flagged by JA4H-based detection are redirected to a challenge page rather than blocked immediately. However, this article is written to stand on its own.

What Is Dynamic Label Interpolation?

AWS WAF rules can attach labels to requests. Managed rule groups such as Bot Control also emit a large number of labels, including values such as:

awswaf:managed:aws:bot-control:bot:category:scraping
Enter fullscreen mode Exit fullscreen mode

Previously, forwarding these label values to the origin or including them in a response required a separate rule for each label and a statically defined header value.

Dynamic label interpolation changes that. With the ${namespace:} syntax, AWS WAF can resolve labels attached to a request at evaluation time and embed the resulting values in custom headers or response bodies.

Request label:        awswaf:managed:aws:bot-control:bot:category:scraping
Configured value:     ${awswaf:managed:aws:bot-control:bot:category:}
Resolved value:       scraping
Enter fullscreen mode Exit fullscreen mode

The resolution rules are straightforward:

  • If exactly one label matches the namespace, AWS WAF returns the final value.
  • If multiple labels match, AWS WAF removes the namespace prefix and returns a comma-separated list, such as scraping,advertising.
  • If no labels match, the placeholder resolves to an empty string.

You can also use built-in values called synthetic labels. Unlike ordinary labels, which are attached to a request as rules evaluate it, synthetic labels resolve information already associated with the request itself, such as the client IP address, TLS handshake fingerprint, or the request ID assigned by AWS WAF.

Synthetic label Value
${awswaf:request_id:} AWS WAF request ID
${awswaf:ip:} Client IP address
${awswaf:ja3:} JA3 TLS fingerprint
${awswaf:ja4:} JA4 TLS fingerprint

Constraints to Understand Before You Design

Before looking at the use cases, let’s review the constraints that affect the architecture. Missing these details can lead to avoidable rework later.

1. Custom request headers can be inserted only by Allow, Count, CAPTCHA, and Challenge actions

A Block action can return only a custom response: a status code, response headers, and a response body.

This is logical when you think about it. “Block the request while also forwarding a header to the origin” is inherently contradictory, because a blocked request never reaches the origin.

2. Inserted request headers receive the x-amzn-waf- prefix

Many AWS WAF users will already be familiar with this behavior. If you configure a rule to insert a header named bot-category, the origin receives it as:

x-amzn-waf-bot-category
Enter fullscreen mode Exit fullscreen mode

The prefix is not added to response headers.

3. With CAPTCHA and Challenge actions, requests without a valid token are not forwarded to the origin

AWS WAF returns the challenge response immediately. Custom request headers are inserted only for requests that pass token inspection and are forwarded.

Conversely, the origin can treat the presence of such a header as evidence that the request has already passed the AWS WAF challenge.

4. Each string value can contain at most 10 placeholders

The eleventh and subsequent placeholders are not resolved. They are emitted literally as ${...}.

This limit applies per string value rather than to the entire request, so you can work around it by splitting the values across multiple headers.

5. Interpolating custom labels requires fully qualified names

In a rule definition, you can attach a custom label using a short name such as:

app:tier:enterprise
Enter fullscreen mode Exit fullscreen mode

However, an interpolation reference must include the full Web ACL context:

${awswaf:ACCOUNT_ID:webacl:WEBACL_NAME:app:tier:}
Enter fullscreen mode Exit fullscreen mode

Label match statements can use the short local namespace, so the two mechanisms are asymmetric in this respect.

6. A namespace with no matching labels resolves to an empty string

Remember to handle the case where the header exists but its value is empty. At the origin, that will usually represent an unclassified request.

Use Case 1: Embed request_id in the Block Page to Create a False-Positive Feedback Loop

False positives are unavoidable in bot mitigation operations.

Security products and corporate proxies may rewrite headers and change a client fingerprint. A legitimate user may also happen to use the same client implementation as one listed as malicious. In either case, a real user can be caught by a blocking rule.

When that happens, the report from the user is often no more specific than, “I cannot access the site.” Identifying which request matched which rule can then take a significant amount of time.

A simple improvement is to embed the AWS WAF request ID directly in the block page.

{
  "CustomResponseBodies": {
    "BlockPage": {
      "Content": "Access has been blocked.\n\nRequest ID: ${awswaf:request_id:}\nIP: ${awswaf:ip:}\n\nIf you believe this is an error, contact support and include the Request ID shown above.",
      "ContentType": "TEXT_PLAIN"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The user only needs to copy the Request ID into the support ticket. The support team can then search the AWS WAF logs for that ID and immediately identify the request, the rule that matched, and every label attached to it.

What used to be “half a day investigating a possible false positive” becomes a single log query. Once the request is confirmed as legitimate, you can exempt the relevant fingerprint or signature and close the case.

This may sound like a modest improvement, but it represents an important shift in design philosophy: make false-positive recovery part of the operational workflow.

Eliminating false positives entirely is unrealistic. It is often more productive to invest in minimizing the cost of recovery when they inevitably occur.

Use Case 2: Carry the Fingerprint into the Challenge Page

An outright block can still damage user trust. In my previous article, I introduced an architecture in which suspicious clients are redirected to a challenge page rather than blocked immediately. Once they pass the challenge, a clearance cookie allows subsequent requests through.

Dynamic label interpolation strengthens that architecture in two places.

During redirection: interpolate values into the Location header

A custom response from a Block action can specify a status code and response headers. Interpolation also works in the Location header.

When redirecting a suspicious request to a challenge page with a 302, you can include the fingerprint and request ID observed at that moment in the query string.

{
  "Name": "suspicious-to-challenge",
  "Statement": {
    "LabelMatchStatement": {
      "Scope": "NAMESPACE",
      "Key": "awswaf:managed:aws:bot-control:bot:category:"
    }
  },
  "Action": {
    "Block": {
      "CustomResponse": {
        "ResponseCode": 302,
        "ResponseHeaders": [
          {
            "Name": "Location",
            "Value": "/challenge?fp=${awswaf:ja4:}&rid=${awswaf:request_id:}"
          },
          { "Name": "Cache-Control", "Value": "no-store" }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The challenge page now knows, from the start, which fingerprint and which request caused the redirect.

You can aggregate challenge impressions and completion rates by fingerprint. This enables data-driven hygiene checks on your fingerprint list—for example, “This fingerprint has a 99% challenge pass rate, so we should remove it from the suspicious list.”

There is one important security caveat: the fp query parameter can be modified by the client. Do not use it for authorization decisions. Limit its use to logging and correlation analysis.

The authoritative fingerprint used for authorization must be observed again during challenge verification, as described next.

After success: issue a clearance cookie bound to the fingerprint

Validate the challenge—using Turnstile, reCAPTCHA, or another mechanism—at the origin. When validation succeeds, issue a clearance cookie protected by an HMAC signature.

Include the JA4 value observed during verification in the signed cookie. The origin can obtain a trusted value from the CloudFront-Viewer-JA4-Fingerprint header forwarded through an origin request policy.

clearance = base64(ja4 + "|" + expiry) + "." + HMAC-SHA256(ja4 + "|" + expiry, secret)
Enter fullscreen mode Exit fullscreen mode

For subsequent requests, validate the cookie in a CloudFront Function on the viewer-request event. In addition to checking the signature and expiration time, compare the JA4 stored in the cookie with the JA4 of the current request.

// Example clearance validation in CloudFront Functions (cloudfront-js-2.0)
var crypto = require('crypto');

function hasValidClearance(request) {
    var c = request.cookies['clearance'];
    var ja4 = request.headers['cloudfront-viewer-ja4-fingerprint'];
    if (!c || !ja4) return false;

    var parts = c.value.split('.');
    if (parts.length !== 2) return false;
    var payload = String.bytesFrom(parts[0], 'base64');  // "ja4|expiry"
    var sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
    if (sig !== parts[1]) return false;

    var fields = payload.split('|');
    if (Number(fields[1]) < Date.now() / 1000) return false;   // Expired
    return fields[0] === ja4.value;                            // JA4 matches
}
Enter fullscreen mode Exit fullscreen mode

This prevents an attacker from stealing only the clearance cookie and reusing it from a different client with a different TLS stack.

Stealing a cookie is one thing. Accurately reproducing the associated TLS fingerprint at the same time is a substantially harder problem.

The Evaluation-Order Trap

As I mentioned in the previous article, AWS WAF evaluates a CloudFront request before CloudFront Functions runs.

The responsibilities in this architecture are therefore divided as follows:

  • AWS WAF: Redirects suspicious requests to the challenge flow with Block + 302, and interpolates ordinary and synthetic labels. AWS WAF cannot calculate an HMAC, so it can check only whether the cookie is present.
  • CloudFront Functions: Cryptographically validates the clearance cookie by checking its HMAC and matching its JA4 value. This is where requests carrying a cookie that AWS WAF allowed through are authenticated.
  • Origin: Verifies the challenge and issues the clearance cookie.

The AWS WAF rule should be structured as follows:

Suspicious label is present AND clearance cookie is absent -> 302 redirect
Enter fullscreen mode Exit fullscreen mode

Requests that have a clearance cookie are allowed through AWS WAF and validated by CloudFront Functions.

An attacker who knows the cookie name can get past the AWS WAF condition by sending a fake cookie, but the function immediately rejects an invalid value. The result is a two-stage defense.

Testing the Actual Behavior

A few details could not be established conclusively from the documentation alone, so I created a test Web ACL and verified them directly.

The test configuration included:

  • A Count rule that inserts headers whose values contain synthetic labels
  • Two rules that attach different custom labels under the same namespace
  • A Block rule that returns a 302 with interpolation in the Location header

For observation, I used a probe CloudFront Function on the viewer-request event that returns all request headers as JSON.

rules.json

[
  {
    "Name": "label-one",
    "Priority": 10,
    "Statement": {
      "ByteMatchStatement": {
        "SearchString": "multi",
        "FieldToMatch": { "SingleHeader": { "Name": "x-waf-test" } },
        "PositionalConstraint": "EXACTLY",
        "TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
      }
    },
    "RuleLabels": [{ "Name": "app:test:one" }],
    "Action": { "Count": {} },
    "VisibilityConfig": { "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true, "MetricName": "labelone" }
  },
  {
    "Name": "label-two",
    "Priority": 11,
    "Statement": {
      "ByteMatchStatement": {
        "SearchString": "multi",
        "FieldToMatch": { "SingleHeader": { "Name": "x-waf-test" } },
        "PositionalConstraint": "EXACTLY",
        "TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
      }
    },
    "RuleLabels": [{ "Name": "app:test:two" }],
    "Action": { "Count": {} },
    "VisibilityConfig": { "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true, "MetricName": "labeltwo" }
  },
  {
    "Name": "forward-signals",
    "Priority": 20,
    "Statement": {
      "SizeConstraintStatement": {
        "FieldToMatch": { "SingleHeader": { "Name": "x-waf-test" } },
        "ComparisonOperator": "GE", "Size": 1,
        "TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
      }
    },
    "Action": {
      "Count": {
        "CustomRequestHandling": {
          "InsertHeaders": [
            { "Name": "test-ja4",     "Value": "${awswaf:ja4:}" },
            { "Name": "test-rid",     "Value": "${awswaf:request_id:}" },
            { "Name": "test-ip",      "Value": "${awswaf:ip:}" },
            { "Name": "test-multi",   "Value": "${awswaf:ACCOUNT_ID:webacl:WEBACL_NAME:app:test:}" },
            { "Name": "test-nomatch", "Value": "${awswaf:ACCOUNT_ID:webacl:WEBACL_NAME:app:nothing:}" }
          ]
        }
      }
    },
    "VisibilityConfig": { "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true, "MetricName": "forwardsignals" }
  },
  {
    "Name": "challenge-redirect",
    "Priority": 30,
    "Statement": {
      "ByteMatchStatement": {
        "SearchString": "block",
        "FieldToMatch": { "SingleHeader": { "Name": "x-waf-test" } },
        "PositionalConstraint": "EXACTLY",
        "TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
      }
    },
    "Action": {
      "Block": {
        "CustomResponse": {
          "ResponseCode": 302,
          "ResponseHeaders": [
            { "Name": "Location",
              "Value": "/challenge?fp=${awswaf:ja4:}&rid=${awswaf:request_id:}" },
            { "Name": "Cache-Control", "Value": "no-store" }
          ]
        }
      }
    },
    "VisibilityConfig": { "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true, "MetricName": "challengeredirect" }
  }
]
Enter fullscreen mode Exit fullscreen mode
# Because this is a Count rule, the headers should be inserted and the
# synthetic-label values should be available.
$ curl -s "https://dxxxx.cloudfront.net/probe" -H "x-waf-test: 1" \
    | jq '.headers[] | select(.name | startswith("x-amzn-waf"))'
{ "name": "x-amzn-waf-test-ja4",     "value": "t13d4907h2_0d8feac7bc37_7395dae3b2f3" }
{ "name": "x-amzn-waf-test-rid",     "value": "F1CXnajGxmUxPQ5e_..." }
{ "name": "x-amzn-waf-test-ip",      "value": "2400:xxxx:..." }
{ "name": "x-amzn-waf-test-multi",   "value": "" }
{ "name": "x-amzn-waf-test-nomatch", "value": "" }

# Verify comma-separated resolution. Two Count rules attach app:test:one
# and app:test:two when x-waf-test is "multi". This shows how
# ${...:app:test:} resolves both labels.
$ curl -s ... -H "x-waf-test: multi" | jq -r '...test-multi...'
one,two

# Verify interpolation in the Location header of a Block custom response.
# Because this is a block rule, the response should redirect to the challenge.
$ curl -sv ... -H "x-waf-test: block" 2>&1 | grep -i '^< location'
< location: /challenge?fp=t13d4907h2_0d8feac7bc37_7395dae3b2f3&rid=SHCiGoKo...
Enter fullscreen mode Exit fullscreen mode

The tests confirmed the following behavior.

Synthetic labels resolve correctly

All three tested synthetic labels—ja4, request_id, and ip—resolved successfully.

Interpolation in the Location header also produced a working redirect.

Multiple labels resolve to a comma-separated list

The two labels resolved as:

one,two
Enter fullscreen mode Exit fullscreen mode

In a real Bot Control deployment, for example, if a single request receives both signal:non_browser_user_agent and signal:automated_browser, then ${...signal:} resolves to:

non_browser_user_agent,automated_browser
Enter fullscreen mode Exit fullscreen mode

A namespace with no matches still inserts the header with an empty value

AWS WAF does not omit the header entirely. The receiving component does not need to test whether the header exists; it only needs to check whether the value is empty.

Headers inserted by AWS WAF are visible to CloudFront Functions on viewer-request

The probe itself ran as a CloudFront Function, so the fact that these headers appeared in its output directly demonstrates that the function could access them.

This last finding significantly expands the available design space.

Because the order is AWS WAF evaluation followed by CloudFront Functions, AWS WAF can pass JA4 to the function without an origin request policy. Bot Control category labels can also drive branching logic in the function—for example, selecting different levels of challenge difficulty.

The JA4 value used for clearance-cookie validation can be delivered through the same path.

As a bonus, I compared JA4 values over HTTP/1.1 and HTTP/2. The results were t13d4907h1_... and t13d4907h2_...: only the single ALPN(Application-Layer Protocol Negotiation) character differed, while the rest of the fingerprint remained identical.

This is a neat real-world demonstration of how ALPN is encoded in the a section of the JA4 format.

Summary

  • Dynamic label interpolation lets AWS WAF resolve classification signals from ordinary labels, along with synthetic values such as request_id, IP address, JA3, and JA4, into headers and response bodies at evaluation time.
  • Adding ${awswaf:request_id:} to a block page turns false-positive investigation into a single log search. Because false positives cannot be eliminated completely, design for lower recovery cost.
  • Interpolation in the Location header can carry a fingerprint into the challenge page. Because query-string values can be tampered with, use them only for logging and reacquire the authoritative value during verification.
  • Binding and signing a clearance cookie to JA4 creates a challenge architecture that cannot be bypassed merely by stealing the cookie.
  • Headers inserted by AWS WAF are visible to CloudFront Functions on viewer-request. This lets you pass JA4 and AWS WAF classification signals to an edge function without an origin request policy.
  • Account for the constraints up front: Block actions cannot insert request headers, inserted headers receive the x-amzn-waf- prefix, each string allows at most 10 placeholders, custom labels require fully qualified interpolation names, and unmatched namespaces resolve to an empty string.

Adding a Request ID to an existing Web ACL’s block page takes about five minutes. That is a great place to start.

Used effectively, dynamic label interpolation could move us toward a future where false positives are no longer something we have to fear when operating bot detection with AWS WAF. Give it a try.

Top comments (0)