DEV Community

Cover image for Blocking Bots with Amazon CloudFront Functions, Final Part: What to Log and How to Detect Them

Blocking Bots with Amazon CloudFront Functions, Final Part: What to Log and How to Detect Them

In the previous article, I built an application detects → edge blocks feedback loop. However, I did not cover the most important part: how the application should actually detect malicious behavior.

Without that piece, many readers may still be wondering what a practical implementation looks like. So, in this final article in the series, I will walk through what to log, which queries to run, how to combine the results into a risk score, and how to register the resulting fingerprints in Amazon CloudFront KeyValueStore (KVS), including code examples.

The basic rule is that a single signal should never result in an immediate ban. Blocking on one indicator alone often produces false positives. Instead, we will add together multiple weighted signals and choose between challenge and block according to the total score.

The analysis stack in this article is Amazon CloudWatch Logs and CloudWatch Logs Insights.

Previous articles in this series

What should you log?

Logs are the foundation of detection. If you later decide that you want to evaluate an additional signal—and that happens often—you cannot detect it if the necessary data was never logged.

Conversely, when the required fields are emitted as structured data, you can add new detection logic later simply by writing another query.

The application should write each access-log record as a single line of structured JSON. The important part is to combine values forwarded by AWS WAF with context known only to the application in the same record.

{
  "ts": "2026-07-17T02:15:30.123Z",
  "request_id": "F1CXnajGxmUxPQ5e_...", // Forwarded from ${awswaf:request_id:}
  "ja4": "t13d4907h2_0d8feac7bc37_7395dae3b2f3", // From AWS WAF or an origin request policy
  "ja4h": "ge11cn05enus_...",
  "client_ip": "203.0.113.10",
  "ua": "Mozilla/5.0 ... Chrome/150...",
  "waf_labels": "bot:category:scraping", // Forwarded label value; may be empty
  "route": "POST /login",
  "status": 401,
  "user_id": null, // Populated only after successful authentication
  "user_id_attempted_hash": "sha256:...", // Optional: hash the attempted account identifier
  "auth_result": "fail", // success | fail | n/a
  "latency_ms": 42
}
Enter fullscreen mode Exit fullscreen mode

Three design choices are particularly important.

Store both JA4 and JA4H

JA4 can change because of TLS session resumption, switching between HTTP/2 and HTTP/3, and other factors. Recording both TLS-based JA4 and HTTP-based JA4H gives you another way to follow the same client when one fingerprint changes.

Populate user_id only after successful authentication

Whether user_id is present lets you distinguish authenticated activity from unauthenticated requests.

For failed login attempts, store the attempted account identifier separately and hash it before logging. That lets you count how many different accounts were targeted without writing raw usernames or email addresses to the access log.

Make auth_result an explicit field

Do not infer login success or failure from the HTTP response code. Record the authentication result directly. This makes the failed-authentication query shown later much simpler and more reliable.

For a Node.js application using Express, you can collect AWS WAF-inserted headers and application context in one logger.

function accessLog(req, res, extra = {}) {
    logger.info(JSON.stringify({
        ts: new Date().toISOString(),
        request_id: req.headers['x-amzn-waf-rid'] || null,
        ja4: req.headers['x-amzn-waf-ja4'] || null,
        ja4h: req.headers['x-ja4h'] || null, // Added by CloudFront Functions in Article 1
        client_ip: req.headers['x-amzn-waf-ip'] || req.ip,
        ua: req.headers['user-agent'],
        waf_labels: req.headers['x-amzn-waf-bot-category'] || '',
        route: `${req.method} ${req.route?.path ?? req.path}`,
        status: res.statusCode,
        user_id: req.user?.id ?? null,
        user_id_attempted_hash: extra.attemptedUserHash ?? null,
        auth_result: extra.authResult ?? 'n/a',
        latency_ms: extra.latencyMs
    }));
}
Enter fullscreen mode Exit fullscreen mode

Make sure your logging configuration emits the object as one JSON event rather than wrapping the JSON string inside another log format.

Three detection signals and their Logs Insights queries

We will write one CloudWatch Logs Insights query for each signal. CloudWatch Logs Insights automatically discovers fields in JSON logs, making structured access logs a natural fit.

The examples below use both JA4 and JA4H as the grouping key so that all signals can later be normalized to the same composite fingerprint.

Signal 1: Repeated authentication failures—the fail2ban pattern

The following query detects a large number of failed login attempts from the same fingerprint within a short period.

fields ja4, ja4h, client_ip, user_id_attempted_hash
| filter route = "POST /login" and auth_result = "fail"
| stats count(*) as fails,
        count_distinct(user_id_attempted_hash) as targets,
        count_distinct(client_ip) as source_ips by ja4, ja4h
| filter fails > 5
| sort fails desc
Enter fullscreen mode Exit fullscreen mode

The key is to examine not only fails, the number of attempts, but also targets, the number of accounts targeted.

Five failures against one account may simply be a user who forgot their password. One attempt against each of 20 accounts is much more likely to be credential stuffing or another automated attack.

Logging a hashed attempted account identifier makes that distinction possible without placing the original identifier in the access log.

Signal 2: Scraping-like behavior and request velocity

Request rates that no human could produce are another valuable signal. One example is the same JA4H fingerprint visiting every page at sub-second intervals.

fields ja4, ja4h, client_ip, toMillis(@timestamp) / 1000 as ts_epoch
| filter isblank(user_id) = 1
| stats count(*) as hits,
        count_distinct(route) as uniq_routes,
        count_distinct(client_ip) as source_ips,
        (max(ts_epoch) - min(ts_epoch)) as span_sec by ja4, ja4h
| filter hits > 100 and span_sec < 60
| sort hits desc
Enter fullscreen mode Exit fullscreen mode

You can calculate requests per second as hits / span_sec, taking care to avoid division by zero. Anything well above a realistic human interaction rate is suspicious.

A high uniq_routes value is also useful evidence that the client is mechanically crawling the entire site rather than following a normal user journey.

The example thresholds—more than 100 requests in under one minute—are only starting points. They must be tuned to the behavior of your own service.

Signal 3: A contradiction between the User-Agent and the fingerprint

Some requests claim to be Chrome in the User-Agent while their JA4 resembles curl, Python, or another non-browser TLS stack.

A User-Agent is trivial to spoof, while accurately reproducing a browser's TLS behavior is considerably harder. That makes this kind of contradiction a strong signal.

CloudWatch Logs Insights cannot infer the expected relationship by itself, so you need to provide a known mapping. For a small observed set, you can place the expected Chrome JA4 values directly in the query.

fields ja4, ja4h, ua, client_ip
| filter ua like /Chrome/
| filter ja4 not in [
    "t13d1516h2_8daaf6152771_...",
    "t13d1517h2_8daaf6152771_..."
  ]
| stats count(*) as hits by ja4, ja4h, ua, client_ip
| sort hits desc
Enter fullscreen mode Exit fullscreen mode

As discussed in Article 3, a genuine Chrome client can produce multiple JA4 values because of session resumption, browser versions, and transport changes. The allowlist therefore needs to contain a set of observed variants rather than a single value.

Comparing a comparatively stable component, such as the JA4 b section within an appropriate client and transport family, is another option. However, do not assume that any component is universally invariant across HTTP/2, HTTP/3, browser upgrades, and every other condition. Validate the comparison against your own observations.

An allowlist also tends to reject newly released browser variants until you have observed them. A denylist can therefore be useful as an alternative or complementary approach. For example, you can calculate JA4 values from traffic observed by a honeypot and register fingerprints that are known to be malicious. Depending on your traffic mix and the quality of the observations, this can remove a large proportion of bot traffic.

Scoring: combining the signals

Each signal can still generate false positives when used alone. Five authentication failures may come from a shared office network, and a previously unseen UA-to-JA4 combination may simply be a new browser release.

Instead, assign a weight to each signal and decide based on the total score.

The sample below uses one consistent detection key: a composite of JA4 and JA4H when both are available. The edge-side lookup must construct the same key.

// Detection Lambda invoked periodically, for example every five minutes
const SIGNALS = [
    { name: 'auth_bruteforce',  weight: 50, query: QUERY_A },
    { name: 'scraping_cadence', weight: 30, query: QUERY_B },
    { name: 'fp_contradiction', weight: 40, query: QUERY_C },
];

const THRESHOLD_CHALLENGE = 40;
const THRESHOLD_BLOCK = 80;

function detectionKey(row) {
    if (row.ja4 && row.ja4h) return `${row.ja4}|${row.ja4h}`;
    return row.ja4 ?? row.ja4h ?? null;
}

async function detect() {
    // key -> { score, reasons[] }
    const scores = {};

    for (const sig of SIGNALS) {
        // runLogsInsights() is assumed to normalize query results into objects.
        const rows = await runLogsInsights(sig.query);

        for (const row of rows) {
            const key = detectionKey(row);
            if (!key) continue;

            scores[key] ??= { score: 0, reasons: [] };
            scores[key].score += sig.weight;
            scores[key].reasons.push(sig.name);
        }
    }

    const puts = [];

    for (const [key, { score, reasons }] of Object.entries(scores)) {
        if (isAllowlisted(key)) continue;

        let action = null;

        if (score >= THRESHOLD_BLOCK) {
            action = 'block';
        } else if (score >= THRESHOLD_CHALLENGE) {
            action = 'challenge';
        }

        if (action) {
            puts.push({
                Key: key,
                Value: JSON.stringify({
                    action,
                    score,
                    reasons,
                    expires: Math.floor(Date.now() / 1000) + ttlFor(action),
                })
            });
        }
    }

    // Article 4: use update-keys in batches and split requests at the API limit.
    await registerToKvs(puts);
}
Enter fullscreen mode Exit fullscreen mode

The intended threshold design is:

  • A single signal can trigger a challenge, but not an immediate block.
  • Multiple overlapping signals are required before the client is blocked.

With the example weights, repeated authentication failures alone score 50 and result in a challenge. Repeated authentication failures plus a fingerprint contradiction score 90 and result in a block.

This expresses the principle used throughout the series—do not stop a person because of only one suspicious sign—as an explicit scoring model.

Naturally, the correct behavior depends on the service. Some environments may be comfortable blocking on one strong signal, while others may not even want to issue a challenge at that point. Adjust the weights, thresholds, and actions to match your risk tolerance and user experience.

Keeping reasons in the KVS value is also important. It lets you answer the question, “Why was this fingerprint banned?” and makes false-positive investigations much easier.

Registering results in KVS with the batch API

As covered in Article 4, bulk KVS updates should use update-keys, the batch API, and split the entries into chunks that fit within the per-request limit.

A sequential loop can take several seconds per entry and is not practical for cleanup or bulk registration.

import {
    CloudFrontKeyValueStoreClient,
    DescribeKeyValueStoreCommand,
    UpdateKeysCommand
} from '@aws-sdk/client-cloudfront-keyvaluestore';

import '@aws-sdk/signature-v4-crt'; // SigV4A

const client = new CloudFrontKeyValueStoreClient({ region: 'us-east-1' });
const CHUNK = 25; // Choose a value safely within the per-request limit.

async function registerToKvs(puts) {
    for (let i = 0; i < puts.length; i += CHUNK) {
        const chunk = puts.slice(i, i + CHUNK);

        // KVS uses optimistic locking, so obtain the latest ETag for each update.
        const { ETag } = await client.send(
            new DescribeKeyValueStoreCommand({ KvsARN: KVS_ARN })
        );

        await client.send(new UpdateKeysCommand({
            KvsARN: KVS_ARN,
            IfMatch: ETag,
            Puts: chunk
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

The CloudFront Functions logic at the edge is the same as in Article 4. It constructs the agreed lookup key, reads the KVS entry, checks action and expires, and then returns either a challenge or a block response.

KVS updates can take tens of seconds to propagate, so do not rely on KVS for instantaneous enforcement. A challenge-first design is safer during that propagation window.

Turning it into an operational loop

Run the detection Lambda periodically—for example, every five minutes with Amazon EventBridge—and the full loop begins to operate:

  1. The application emits structured logs to CloudWatch Logs.
  2. Every five minutes, the detection Lambda runs the three Logs Insights queries and aggregates their scores.
  3. Fingerprints above the threshold are registered in KVS. A client can begin at challenge and later be promoted to block after repeated detections.
  4. Subsequent requests are challenged or blocked at the edge after the KVS update propagates.
  5. The TTL eventually expires, providing automatic recovery from false positives.

For initial tuning, it may be wise to register every result as challenge and promote entries to block manually. “Manually” can still mean reviewing the data and then running a prepared batch job.

Observe the recorded reasons and challenge pass rates for a while. Fingerprints that repeatedly show a zero-percent pass rate are stronger candidates for blocking. Use those observations to tune the weights and thresholds before enabling automatic blocking.

Starting with automatic blocking is risky because a bug in the detection logic can immediately deny access to legitimate users. This is one area where boring, gradual rollout is absolutely a feature.

Summary

  • Detection depends on good logs. Put AWS WAF values such as JA4, request IDs, and labels in the same structured record as application context such as user_id and auth_result.
  • Do not immediately ban on a single signal. Detect repeated authentication failures, scraping-like behavior, and fingerprint contradictions with CloudWatch Logs Insights, then combine them with weighted scoring.
  • Use a challenge for a single signal and block only when multiple signals overlap. This turns “do not stop a person because of one suspicious sign” into an explicit policy.
  • Store reasons in KVS. You will need them when investigating a false positive.
  • Use the KVS batch API and chunk requests. Sequential updates are not practical at scale.
  • Begin with challenges and manual promotion to block. Expand automation only after observing challenge pass rates and tuning the model.

This completes the series:

  • JA4H calculation in Article 1
  • AWS WAF signal forwarding in Article 2
  • Fingerprint variability in Article 3
  • Edge enforcement and the feedback loop in Article 4
  • The detection engine in this article

Combined, they form a bot-mitigation architecture in which the application and the edge cooperate: the application detects behavior using rich context, while the edge enforces the decision before the next malicious request reaches the origin.

That is all for this series—for now. If AWS adds new capabilities or I come up with another useful idea, I will write a follow-up.

I would be delighted to see you implement this approach, adapt it to your own requirements, and improve on it. I hope it helps you make your own environment more secure.

Top comments (0)