Protecting Web Applications at the Edge with AWS WAF
Web Application Firewalls are no longer optional infrastructure components—they're essential layers between your users and application servers. WAF with Terraform in Terraform, IaC & Cloud Automation enables teams to codify security rules alongside their infrastructure, ensuring consistent protection across environments and making security reviews as routine as code reviews. This article walks through managed rule groups, rate-based rules, association patterns, and count mode testing to build a production-grade WAF that stops attacks before they reach your application.
Why WAF Matters in Infrastructure as Code
In many organizations, WAF rules live in the AWS console—scattered, undocumented, and inconsistent between staging and production. When we treat WAF as code, we inherit all the benefits of IaC: version control, peer review, rollback capability, and reproducibility. We can test rule changes in a development environment, merge them through Git, and deploy with confidence. More importantly, we can attach WAF to CloudFront distributions or Application Load Balancers with Terraform, creating an immutable relationship between our edge protection and the resources it shields.
Managed Rule Groups: Leveraging AWS-Maintained Protections
AWS maintains dozens of managed rule groups covering OWASP Top 10 vulnerabilities, common exploits, and protocol abuse. Rather than writing detection rules from scratch, we inherit collective knowledge from AWS security teams and integrate these rules into our Terraform definitions. The most commonly used groups are:
- AWSManagedRulesCommonRuleSet — Blocks SQL injection, cross-site scripting (XSS), local file inclusion, and other baseline attacks.
- AWSManagedRulesKnownBadInputsRuleSet — Protects against requests known to be malicious.
- AWSManagedRulesAmazonIpReputationList — Blocks IPs flagged by AWS threat intelligence.
- AWSManagedRulesLinuxRuleSet — Targets Linux-specific attack patterns.
Here's how we define a WAF WebACL with the Common Rule Set in Terraform:
resource "aws_wafv2_web_acl" "app_waf" {
name = "app-waf-prod"
scope = "CLOUDFRONT"
default_action {
allow {}
}
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 0
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSetMetric"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "app-waf-prod-metrics"
sampled_requests_enabled = true
}
}Notice the override_action set to none — this means we inherit the rule group's default actions (block or allow). We'll revisit this when discussing count mode. The visibility_config enables CloudWatch metrics and sampled request logging, critical for monitoring and debugging.
Rate-Based Rules: Stopping Brute Force and Abuse
Managed rule groups handle known attack signatures, but rate-based rules protect against volume-based abuse: credential stuffing, DDoS attempts, and API scraping. A rate-based rule counts requests from a single IP address over a 5-minute window and triggers when that count exceeds a threshold. This is where WAF becomes proactive.
Consider an e-commerce platform receiving thousands of login attempts from a single IP in minutes. A rate-based rule can block that IP before the attack consumes server resources or locks legitimate users out of their accounts. Here's how we add one:
rule {
name = "RateLimitRule"
priority = 1
action {
block {}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimitRuleMetric"
sampled_requests_enabled = true
}
}A limit of 2,000 requests per 5 minutes (roughly 400 req/min per IP) is reasonable for most APIs; adjust based on legitimate user behavior. We can also refine this using scope-down statements to apply rate limiting only to specific paths or methods, avoiding false positives from legitimate traffic spikes.
For example, if legitimate users flood /health-check endpoints, we exempt that path:
rule {
name = "RateLimitRule"
priority = 1
action {
block {}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
scope_down_statement {
byte_match_statement {
positional_argument = "EXACTLY"
field_to_match {
uri_path {}
}
text_transformation {
priority = 0
type = "LOWERCASE"
}
search_string = "/health-check"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimitRuleMetric"
sampled_requests_enabled = true
}
}Associating WAF to CloudFront and Load Balancers
A WAF is only effective when attached to an entry point. In AWS, that's typically a CloudFront distribution (for edge protection) or an Application Load Balancer (for regional protection). The association is straightforward in Terraform but requires the WAF to be in the right scope: CLOUDFRONT for distributions, REGIONAL for ALBs.
Attaching to a CloudFront distribution:
resource "aws_cloudfront_distribution" "app_cdn" {
enabled = true
origin {
domain_name = aws_lb.app_alb.dns_name
origin_id = "app-alb"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "app-alb"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = true
cookies {
forward = "all"
}
headers = ["Authorization", "Host"]
}
}
web_acl_id = aws_wafv2_web_acl.app_waf.arn
viewer_certificate {
cloudfront_default_certificate = false
acm_certificate_arn = aws_acm_certificate.app_cert.arn
ssl_support_method = "sni-only"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
}Note the web_acl_id = aws_wafv2_web_acl.app_waf.arn line—this is the association. Every request to the CloudFront distribution now flows through the WAF before reaching the origin.
For a regional ALB:
resource "aws_wafv2_web_acl" "alb_waf" {
name = "alb-waf-prod"
scope = "REGIONAL"
# ... rules as before ...
}
resource "aws_wafv2_web_acl_association" "alb_association" {
resource_arn = aws_lb.app_alb.arn
web_acl_arn = aws_wafv2_web_acl.alb_waf.arn
}With CloudFront, the association is inline. With ALBs, we use a dedicated aws_wafv2_web_acl_association resource. Both patterns achieve the same goal: all incoming traffic is evaluated against the WAF before reaching your application.
Count Mode: Safe Testing Before Blocking
Deploying a blocking WAF without testing is like launching a firewall rule in production that shuts down legitimate traffic. Count mode is the solution: rules run and generate metrics, but they don't block requests. We test in count mode, review metrics in CloudWatch, fine-tune thresholds, and only then switch to blocking mode.
To use count mode, set the override_action or action to count instead of block:
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 0
override_action {
count {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSetMetric"
sampled_requests_enabled = true
}
}Now, when a request matches a rule, WAF records the metric and allows the request through. We monitor CloudWatch for 24-48 hours, looking for patterns: are legitimate requests being flagged? Are false positives high? Once confident, we change count to block and redeploy.
Here's a practical workflow:
- Deploy WAF with all rules in count mode.
- Observe CloudWatch metrics for 1-2 days under normal and peak load.
- Identify rules with high false positive rates and review sampled requests.
- Add scope-down statements to exclude legitimate traffic (e.g., exempt admin paths).
- Switch blocking rules to production and monitor errors.
- Create CloudWatch alarms to alert on blocked request spikes.
Common Mistakes
Forgetting CloudWatch metrics: Without visibility_config enabled, you're blind to what the WAF is doing. Always enable metrics and sampled requests.
Blocking too aggressively: A WAF that blocks 5% of legitimate traffic is worse than no WAF. Start in count mode and measure before blocking.
Ignoring rule priorities: Rules are evaluated in order of priority (0 is first). If an earlier rule matches, later rules may not evaluate. Order matters—place high-confidence rules first.
Mixing scopes: Using a CLOUDFRONT WAF on an ALB will fail silently. Always verify scope matches the resource.
Not logging associated requests: Enable sampled request logging to see what requests are being flagged. This is invaluable for debugging false positives.
Key Takeaways
- Managed rule groups give you battle-tested protections (OWASP, known exploits, IP reputation) without writing rules from scratch.
- Rate-based rules stop volume-based abuse like credential stuffing and API scraping by limiting requests per IP over time.
- Attach WAF to CloudFront for edge protection or ALBs for regional protection using Terraform's
web_acl_idoraws_wafv2_web_acl_association. - Count mode is mandatory before blocking—test for 1-2 days, measure false positives, and only enable block action once confident.
- Invest in CloudWatch metrics and sampled request logging; visibility is your best debugging tool.






Comments
Loading comments…