Bot mitigation has an asymmetric structure. The application is where malicious behavior is easiest to detect.
“This account has seen 30 failed login attempts in five minutes.”
“A human user would never call these APIs in this sequence.”
Only the application has that kind of context.
On the other hand, the edge is where enforcement is easiest. If you can drop malicious requests before they reach the origin, you avoid wasting application resources and generating unnecessary logs.
So, in this article, I am going to connect the two and build an application detects → edge blocks feedback loop using AWS WAF Dynamic Label Interpolation and Amazon CloudFront KeyValueStore (KVS).
If the phrase “fail2ban at the edge” speaks to you, you probably already see where this is going.
Previous articles in this series
- Article 1: Implementing a JA4H-Equivalent Fingerprint with Amazon CloudFront Functions to Mitigate Bot Traffic
- Article 2: Building a False-Positive-Resilient Challenge Architecture with AWS WAF Dynamic Label Interpolation
- Article 3: Mitigating Session Hijacking with JA4 Session Binding and AWS WAF Dynamic Label Interpolation
Architecture
[Client]
|
v
[AWS WAF] --- A Count rule inserts x-amzn-waf-ja4 and WAF labels
| into the request (Article 2)
v
[CloudFront Functions] --- Looks up KVS and challenges or blocks
| registered fingerprints (Articles 1 and 2)
v
[Origin application] --- Uses WAF signals plus application context
| to identify malicious behavior
| (repeated authentication failures,
| abnormal navigation, rate patterns, and so on)
|
+--> [PutKey to KVS] --- Subsequent requests are stopped at the edge
once the update propagates ----------------+
|
[CloudFront Functions] <-------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode
This architecture brings together the roles covered throughout the earlier articles:
- AWS WAF supplies signals by forwarding JA4 and WAF labels.
- CloudFront Functions looks up KVS and decides whether to allow, challenge, or block a request.
- The application performs context-aware detection and registers malicious fingerprints in KVS.
Each component does only the job it is best suited for.
A note on production operations
I used direct application-to-KVS updates here because they make the concept easier to demonstrate. In a production system, a cleaner separation would often be to have the application write only logs, then run a dedicated batch process, Lambda function, or similar component that evaluates those logs and updates KVS.
Implementation
Registering fingerprints from the application in KVS
The application—or a Lambda function monitoring its logs—registers fingerprints that it has classified as malicious.
// Node.js (AWS SDK v3)
import {
CloudFrontKeyValueStoreClient,
DescribeKeyValueStoreCommand,
PutKeyCommand
} from '@aws-sdk/client-cloudfront-keyvaluestore';
import '@aws-sdk/signature-v4-crt'; // The KVS API requires SigV4A signing
const client = new CloudFrontKeyValueStoreClient({ region: 'us-east-1' });
const KVS_ARN = 'arn:aws:cloudfront::ACCOUNT_ID:key-value-store/xxxx';
async function banFingerprint(ja4, action, ttlSeconds) {
// KVS updates use optimistic locking. Fetch the ETag before writing.
const { ETag } = await client.send(
new DescribeKeyValueStoreCommand({ KvsARN: KVS_ARN })
);
await client.send(new PutKeyCommand({
KvsARN: KVS_ARN,
IfMatch: ETag,
Key: ja4,
Value: JSON.stringify({
action, // "challenge" | "block"
expires: Math.floor(Date.now() / 1000) + ttlSeconds,
reason: 'auth-bruteforce' // For auditing
})
}));
}
Enter fullscreen mode Exit fullscreen mode
Looking up entries from CloudFront Functions
This is essentially the challenge-routing logic from Article 2 with an expiration check added.
import cf from 'cloudfront';
const kvs = cf.kvs();
async function handler(event) {
const request = event.request;
// Inserted by AWS WAF and visible to the viewer-request function
const ja4 = request.headers['x-amzn-waf-ja4'];
if (ja4) {
try {
const entry = JSON.parse(await kvs.get(ja4.value));
if (entry.expires > Date.now() / 1000) {
if (entry.action === 'block') {
return {
statusCode: 403,
statusDescription: 'Forbidden'
};
}
if (
entry.action === 'challenge' &&
!hasValidClearance(request)
) {
return redirectToChallenge(request);
}
}
// Expired entries are allowed through, providing automatic recovery.
} catch (e) {
// The fingerprint is not registered.
}
}
return request;
}
Enter fullscreen mode Exit fullscreen mode
Design considerations
Implement TTL yourself
KVS does not provide a native TTL mechanism, so the value needs to include an expires field, with the function checking it at request time as shown above.
The purpose is to provide automatic recovery from false positives. Even if a legitimate user’s fingerprint is accidentally registered, the restriction disappears when the TTL expires.
As discussed in Article 2, false positives cannot be eliminated completely. The practical goal is therefore to reduce their recovery cost.
Expired entries can be physically removed by a daily cleanup job that runs ListKeys and then deletes expired entries with DeleteKey. I will return to cleanup performance later in this article.
Escalating from challenge to block
It is safer to register a fingerprint as challenge first rather than blocking it immediately.
You can then measure its challenge pass rate—assuming the fingerprint is carried through the Location header using the interpolation approach from Article 2. A fingerprint that has been shown a challenge 1,000 times and passed zero times is a strong candidate for escalation to block.
Conversely, a high pass rate is evidence of a false positive, which means the thresholds used to register fingerprints should be reviewed.
Insight from Article 3: designing ban-list keys
As demonstrated in Article 3, JA4 can change even for the same client because of TLS session resumption, switching between HTTP/2 and HTTP/3, and similar factors.
That means a block rule based on an exact JA4 match may be bypassed when the attacker produces a different JA4 variant—for example, through session resumption.
The key design therefore needs to account for JA4 variation:
- Use a comparatively stable component, such as the JA4
bsection where appropriate, as the key, or register the small set of observed variants together. - Use both JA4 for the TLS stack and JA4H for the HTTP client, as introduced in Article 1. Reproducing both the TLS stack and the HTTP client behavior at the same time is substantially more expensive for an attacker.
Preventing accidental bans
If detection and registration are automated, safeguards for both prevention and recovery are essential.
- Registration rate limit: Cap the number of new entries that can be added within a given time window. If the limit is exceeded, raise an alert and bring in a human. This helps prevent a bug in the detection logic from banning every user.
- Pre-registration allowlist: Exclude fingerprints belonging to clients that must never be blocked, such as your own monitoring agents, search-engine crawlers, and payment-provider callbacks.
- KVS capacity management: KVS has limits on total capacity and entry count, so check the current values in the documentation. Do not design a ban list that grows forever. Use TTLs and a daily cleanup process, and estimate the expected steady-state size before deciding whether the design is viable.
-
Kill switch: Store a control key such as
_global_disablein KVS and have the function check it first. If the loop runs out of control, one key update can disable the entire mechanism.
These safeguards need to be adapted to the service. A consumer service may legitimately experience sudden registration spikes during a campaign, for example. Conversely, a B2B service may be able to apply stricter controls, such as tighter registration-rate limits and more explicit handling of known crawlers.
Watermarking: tracing copied content back to the request
One useful by-product of the feedback loop is the ability to watermark block responses.
For requests classified as scrapers, you can embed a per-request ID in the block page by using dynamic label interpolation.
{
"CustomResponseBodies": {
"ScraperPage": {
"Content": "<!-- ref: ${awswaf:request_id:} -->\n<html>...(decoy or degraded content)...</html>",
"ContentType": "TEXT_HTML"
}
}
}
Enter fullscreen mode Exit fullscreen mode
Unsophisticated scrapers often store and republish even block pages or decoy content without modification.
If you later find this ID on a copied page, you can query the WAF logs by request_id and identify the acquisition time, IP address, and fingerprint associated with the request.
This also provides evidence about which ban candidates are actually extracting content, giving you another signal when deciding whether to escalate them to block.
Do AWS WAF and an origin request policy produce the same JA4 value?
To be safe, I checked whether JA4 obtained through AWS WAF was identical to the value obtained through the origin request policy path.
In my test, the values were the same:
x-amzn-waf-test-ja4: t13d4907h2_0d8feac7bc37_7395dae3b2f3
cloudfront-viewer-ja4-fingerprint: t13d4907h2_0d8feac7bc37_7395dae3b2f3
Enter fullscreen mode Exit fullscreen mode
That means you can choose the acquisition path based on operational convenience.
With a WAF Count rule and dynamic label interpolation, you can forward JA4 together with other labels, such as Bot Control categories, through the same mechanism.
With an origin request policy, you can obtain JA4 without adding another WAF rule, which may be the simpler option in some environments.
KVS propagation latency
I measured how long it took from writing a unique value with PutKey until an edge function could read it.
For the test, I prepared a probe function that did nothing except read the value from KVS and return it, then started polling immediately after the write.
At one test location, the observed times were:
31 seconds
29 seconds
37 seconds
Enter fullscreen mode Exit fullscreen mode
In other words, propagation took roughly 30 seconds in these tests.
KVS is eventually consistent, and CloudFront Functions can cache values within their execution environments, so this appears to represent the combined time for write propagation and cache refresh.
The important point is that an update does not become visible everywhere in the world the instant it is written.
More importantly, during propagation, the probe sometimes alternated between the old and new values:
Elapsed 0 seconds: new value
Elapsed 5 seconds: old value <- reverted
Elapsed 9 seconds: new value
Elapsed 15 seconds: old value <- reverted again
...
Elapsed 37 seconds: stable on the new value
Enter fullscreen mode Exit fullscreen mode
My guess is that multiple execution environments exist even within the same edge location, and their caches refresh at slightly different times. Depending on which execution environment handles a request, old and new values can therefore appear interleaved.
This matters when designing a ban mechanism.
Even after a fingerprint has been registered in KVS, requests from the same attacker may sometimes pass and sometimes be stopped for several tens of seconds, until every relevant execution environment reads the new value.
For that reason, it can be useful not to rely on KVS alone for immediate containment. In this design, a challenge can be introduced first, and TTLs provide recovery from false positives.
A rule set built around the assumption that “registered means blocked immediately” may overlook this transitional state.
Cleanup throughput: sequential updates are painful, and batch updates have limits too
I also measured what happens when expired entries are deleted—or large numbers of entries are added—using a simple loop.
When I ran put-key one entry at a time and fetched a fresh ETag with describe-key-value-store before every write for optimistic locking, 10 entries took approximately 25 seconds, or roughly 2.5 seconds per entry.
I tried 100 entries as well, but aborted the test after 3 minutes and 48 seconds because it still had not finished.
This approach also pays the process startup and authentication overhead of every individual AWS CLI command, so it is not practical.
By contrast, when I used update-keys, the batch API, to combine the same 10 put or delete operations into a single request, the operation completed in approximately 1.4 seconds.
Because the batch version needs only one request, the performance gap grows with the number of entries.
Method Time for 10 entries Sequential: oneput-key plus a fresh ETag for every entry
Approximately 25.6 seconds
Batch: one update-keys request
Approximately 1.4 seconds
There is one important caveat: the batch API limits how many key-value pairs can be updated in a single request.
When I tried to delete 80 entries at once, the request failed with:
ServiceQuotaExceededException:
Maximum key values pairs that you can update in a single API request
Enter fullscreen mode Exit fullscreen mode
Large operations therefore need to be split into chunks and processed in a loop. In this test, I used chunks of 25 entries.
If you are writing a cleanup job for the ban list, this chunking behavior should be part of the design from the beginning.
Summary
- Detection belongs in the application—or in a batch process that analyzes its logs—while enforcement belongs at the edge. Dynamic label interpolation provides the downstream signal path, and KVS provides the upstream decision path, creating a bidirectional feedback loop.
- Ban-list keys need to account for JA4 variation. A layered design using both JA4 and JA4H is likely the safest approach.
- Watermarking lets you trace copied content back to the request that retrieved it.
- JA4 obtained through AWS WAF dynamic label interpolation and JA4 obtained through
CloudFront-Viewer-JA4-Fingerprintwere identical in my test, so the acquisition path can be selected according to operational needs. - A KVS update is not an immediate, deterministic block. In my tests, propagation took roughly 30 seconds, and old and new values were interleaved during that period. Designs that introduce a challenge first and use TTLs are more tolerant of this behavior.
- Use
update-keys, the batch API, for bulk KVS operations. Sequential loops are impractical, while batch requests must still be divided into chunks to stay within the per-request limit.
I fully understand that, depending on the organization and operating model, there may be strong pressure to “solve everything with WAF alone.”
I wrote this article because I wanted to demonstrate that combining WAF with application-level context can raise the security bar significantly.
I hope this gives you some useful ideas for your own architecture.
답글 남기기