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

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

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

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. 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. However, an interpolation reference must include the full Web ACL context: ${awswaf:ACCOUNT_ID:webacl:WEBACL_NAME:app:tier:}. 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\n Request ID: ${awswaf:request_id:} \n IP: ${awswaf:ip:} \n\n If you believe this is an error, contact support and include the Request ID shown above.",
      "ContentType" : "TEXT_PLAIN"
    }
  }
}

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"
          }
        ]
      }
    }
  }
}

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)

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
}

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

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": {
        "SearchStr

Comments

No comments yet. Start the discussion.