AWS WAF v2: Rate Limiting, Bot Control, and Custom Rules
AWS WAF v2 launched in 2019 and the original WAF Classic is end-of-life — migration ended November 2024. If you’re still on Classic, those web ACLs are frozen. This guide covers WAF v2 specifically: the rule model, rate-based rules for brute force and DDoS protection, the managed rule groups worth paying for, Bot Control, and logging. Pricing first: a web ACL costs $5/month, each rule costs $1/month, and you pay $0.60 per million requests evaluated. Bot Control adds $10/month for the ACL plus $1 per million requests at the common level, and $30/month plus $1.50 per million at targeted level.
WAF v2 attaches to four resource types: CloudFront distributions, Application Load Balancers, API Gateway stages, and AppSync GraphQL APIs. The scope setting is either CLOUDFRONT (global, must be deployed in us-east-1) or REGIONAL (for ALB, API Gateway, AppSync in any region). A single web ACL can only attach to one scope type.
Web ACL Structure
Think of a web ACL as a series of filters. A request enters at rule priority 0 and travels down the list until something matches — then the rule’s action fires and the request stops. Anything that reaches the bottom without matching takes the default action. This ordering matters: a broad ALLOW rule at priority 0 can neutralize every block rule below it, so the rule order in your JSON is load-bearing, not decorative.
# Create a basic web ACL attached to an ALB
aws wafv2 create-web-acl \
--name production-waf \
--scope REGIONAL \
--default-action Allow={} \
--region us-east-1 \
--visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=production-waf \
--rules file://rules.json
# Associate with an ALB
ALB_ARN=$(aws elbv2 describe-load-balancers \
--names my-production-alb \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
WAF_ARN=$(aws wafv2 list-web-acls --scope REGIONAL --region us-east-1 \
--query 'WebACLs[?Name==`production-waf`].ARN' --output text)
aws wafv2 associate-web-acl \
--web-acl-arn $WAF_ARN \
--resource-arn $ALB_ARN \
--region us-east-1
Rule priority is an integer: lower priority number = evaluated first. Build your rule order with this pattern:
- IP allowlist (your monitoring, health checks) — priority 0
- IP blocklist — priority 10
- Rate-based rules — priority 20
- AWS managed rule groups — priority 30
- Custom business logic rules — priority 40
Rate-Based Rules
WAF’s rate limiter counts requests in a rolling 5-minute window. When a source crosses the threshold, it gets blocked for the rest of that window. The count resets as the window slides forward, so a blocked IP that stops sending gets unblocked automatically — no manual intervention needed. The threshold you pick matters: set it too low and you catch legitimate users during traffic spikes; too high and an attacker can brute-force a login form well below the limit.
{
"Name": "limit-login-attempts",
"Priority": 20,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"ByteMatchStatement": {
"SearchString": "/api/auth/login",
"FieldToMatch": {"UriPath": {}},
"TextTransformations": [{"Priority": 0, "Type": "NONE"}],
"PositionalConstraint": "STARTS_WITH"
}
}
}
},
"Action": {"Block": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "LimitLoginAttempts"
}
}
The ScopeDownStatement is the part people skip and then wonder why they’re blocking legitimate traffic. Without it, the rate limit applies to every request from an IP — including your mobile app, your internal health checks, and your partner integrations. A user on a heavily shared corporate NAT who makes 101 API calls gets blocked. Scope the rule to the exact URL path, header, or method you’re defending.
For header-based rate limiting (useful when your load balancer is behind a CDN that masks client IPs):
{
"Name": "limit-by-api-key",
"Priority": 25,
"Statement": {
"RateBasedStatement": {
"Limit": 1000,
"AggregateKeyType": "CUSTOM_KEYS",
"CustomKeys": [
{
"Header": {
"Name": "X-API-Key",
"TextTransformations": [{"Priority": 0, "Type": "NONE"}]
}
}
]
}
},
"Action": {"Block": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "LimitByApiKey"
}
}
CUSTOM_KEYS aggregation (introduced in WAF v2’s 2023 update) supports grouping by IP, header, query argument, cookie, HTTP method, or URI path. You can combine up to 5 keys — for example, rate limiting per {IP, URL path} pair catches attackers who spread requests across endpoints.
AWS Managed Rule Groups
AWS publishes rule groups that cover common attack patterns. You pay $1/month per rule group plus request evaluation costs. The ones worth enabling for most applications:
{
"Name": "AWSManagedRulesCommonRuleSet",
"Priority": 30,
"OverrideAction": {"Count": {}},
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet"
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "CommonRuleSet"
}
}
Start with "OverrideAction": {"Count": {}} — this puts the whole group in COUNT mode, where matches are logged but requests aren’t blocked. Run this for 1–2 weeks, review the CloudWatch metrics to see what’s being matched, then flip to "OverrideAction": {"None": {}} to enable blocking. Skipping the COUNT phase and going straight to block is how you accidentally block legitimate traffic.
Rule groups to consider:
| Group Name | Purpose | Cost |
|---|---|---|
AWSManagedRulesCommonRuleSet |
OWASP Top 10, XSS, SQL injection | $1/month |
AWSManagedRulesKnownBadInputsRuleSet |
Log4j, Spring4Shell, SSRF patterns | $1/month |
AWSManagedRulesAmazonIpReputationList |
AWS threat intel IP blocklist | $1/month |
AWSManagedRulesAnonymousIpList |
Tor exit nodes, VPN providers | $1/month |
AWSManagedRulesSQLiRuleSet |
SQL injection (additional coverage) | $1/month |
The AmazonIpReputationList is one of the better free signals — it blocks IP addresses that AWS’s threat intel identifies as participating in botnets or scanning activity. At $1/month, it’s worth it for any public-facing endpoint.
Overriding Individual Rules Within a Group
The managed rule groups contain dozens of individual rules. Some of them will generate false positives for your specific application. Rather than disabling the whole group, override individual rules:
{
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet",
"RuleActionOverrides": [
{
"Name": "SizeRestrictions_BODY",
"ActionToUse": {"Count": {}}
},
{
"Name": "CrossSiteScripting_BODY",
"ActionToUse": {"Count": {}}
}
]
}
}
}
If your API accepts large request bodies (file uploads, GraphQL queries), SizeRestrictions_BODY will block them. Override that individual rule to COUNT while keeping the rest of the group blocking.
Bot Control
Bot Control is a separate managed rule group with its own pricing. The Common level ($10/month + $1/million requests) detects bots based on user agent signatures, behavioral patterns, and browser fingerprinting. The Targeted level ($30/month + $1.50/million) adds JavaScript challenges and browser verification — similar to Cloudflare’s bot management.
{
"Name": "AWSManagedRulesBotControlRuleSet",
"Priority": 35,
"OverrideAction": {"None": {}},
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesBotControlRuleSet",
"ManagedRuleGroupConfigs": [
{
"AWSManagedRulesBotControlRuleSet": {
"InspectionLevel": "COMMON",
"EnableMachineLearning": true
}
}
]
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "BotControl"
}
}
Bot Control at the Common level verifies whether requests come from known bots. AWS maintains lists of good bots (Googlebot, Bingbot, etc.) and bad bots (scrapers, credential stuffers). Good bots are allowed through; bad bots are blocked.
One important caveat: Bot Control counts mobile apps and some API clients as bots if they don’t include a recognized browser user agent. Before enabling in production, run the group in COUNT mode and check the label breakdown in WAF logs:
# Check what labels Bot Control is applying
aws wafv2 get-sampled-requests \
--web-acl-arn $WAF_ARN \
--rule-metric-name BotControl \
--scope REGIONAL \
--time-window StartTime=$(date -d '1 hour ago' -u +%Y-%m-%dT%H:%M:%SZ),EndTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
--max-items 100 \
--query 'SampledRequests[*].{IP:Request.ClientIP,UA:Request.Headers[?Name==`User-Agent`].Value|[0],Labels:Labels[*].Name}' \
--output table
CAPTCHA Actions
WAF v2 supports serving CAPTCHA challenges (via AWS’s embedded JavaScript widget) instead of blocking. This is useful for rate-limited actions where you want to distinguish humans from bots:
{
"Name": "captcha-suspicious-login",
"Priority": 22,
"Statement": {
"RateBasedStatement": {
"Limit": 50,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"ByteMatchStatement": {
"SearchString": "/login",
"FieldToMatch": {"UriPath": {}},
"TextTransformations": [{"Priority": 0, "Type": "NONE"}],
"PositionalConstraint": "STARTS_WITH"
}
}
}
},
"Action": {
"Captcha": {
"CustomRequestHandling": {
"InsertHeaders": [{"Name": "x-amzn-waf-action", "Value": "captcha"}]
}
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "CaptchaLoginSuspicious"
}
}
CAPTCHA requires the frontend to include the AWS WAF JavaScript integration or use the AWS WAF token API. It doesn’t work for pure API clients — use BLOCK for those.
WAF Logging
Enable logging to understand what WAF is doing before and after enabling block mode:
# Create a Kinesis Data Firehose delivery stream first (name must start with aws-waf-logs-)
aws firehose create-delivery-stream \
--delivery-stream-name aws-waf-logs-production \
--s3-destination-configuration \
RoleARN=arn:aws:iam::123456789012:role/firehose-role,\
BucketARN=arn:aws:s3:::my-waf-logs-bucket,\
Prefix=waf-logs/,\
BufferingHints={SizeInMBs=5,IntervalInSeconds=300}
# Enable WAF logging
aws wafv2 put-logging-configuration \
--logging-configuration \
ResourceArn=$WAF_ARN,\
LogDestinationConfigs=arn:aws:firehose:us-east-1:123456789012:deliverystream/aws-waf-logs-production,\
RedactedFields=[{SingleHeader:{Name:authorization}},{SingleHeader:{Name:cookie}}]
WAF logs include the matching rule name, the request headers, the action taken, and the labels applied. They’re JSON, and Athena can query them directly:
-- Top blocked IPs in the last 24 hours
SELECT httprequest.clientip,
COUNT(*) AS block_count
FROM waf_logs
WHERE action = 'BLOCK'
AND from_unixtime(timestamp/1000) > NOW() - INTERVAL '1' DAY
GROUP BY httprequest.clientip
ORDER BY block_count DESC
LIMIT 20;
Terraform Configuration
Managing WAF in the console is viable for small setups but gets unwieldy. Terraform’s aws_wafv2_web_acl resource covers everything:
resource "aws_wafv2_web_acl" "production" {
name = "production-waf"
scope = "REGIONAL"
default_action {
allow {}
}
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 30
override_action {
count {} # Start in COUNT mode
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "CommonRuleSet"
sampled_requests_enabled = true
}
}
rule {
name = "rate-limit-api"
priority = 20
action {
block {}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimitAPI"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "production-waf"
sampled_requests_enabled = true
}
}
resource "aws_wafv2_web_acl_association" "alb" {
resource_arn = aws_lb.production.arn
web_acl_arn = aws_wafv2_web_acl.production.arn
}
For the broader security posture, WAF works alongside AWS GuardDuty (which detects threats at the account level) and AWS Security Hub (which aggregates WAF findings with findings from other security services into a central dashboard).
Comments