Back to Blog

AWS WAF in 2026: Rate Limiting, Bot Protection, OWASP Rules, and Terraform

Configure AWS WAF for production security: managed rule groups, custom rate limiting, bot control, IP reputation lists, OWASP Top 10 protection, and complete Terraform setup.

Viprasol Tech Team
January 4, 2027
13 min read

AWS WAF in 2026: Rate Limiting, Bot Protection, OWASP Rules, and Terraform

AWS WAF sits in front of your CloudFront distribution or Application Load Balancer and filters malicious traffic before it reaches your application. Done right, it handles the OWASP Top 10, stops credential stuffing, blocks scrapers, and rate-limits abusive IPs—without you writing a single line of application firewall logic.

Done wrong, it's a bill with opaque metrics, false positives blocking real users, and security gaps because the managed rules weren't tuned for your traffic patterns.

This post covers the production WAF setup we deploy for client applications at Viprasol: managed rule groups, custom rate limiting by IP and by endpoint, bot control, geo-restrictions, and the Terraform that manages it with proper logging.


WAF Architecture Decision: CloudFront vs ALB

PlacementUse WhenAdvantage
CloudFrontNext.js, static sites, global usersBlocks at the edge (cheapest, fastest)
ALBDirect API traffic, ECS/EC2Protects backend regardless of frontend
BothMaximum protectionLayer defense

For most Next.js SaaS products: WAF on CloudFront. For APIs accessed directly (mobile apps, third-party integrations): WAF on ALB as well.


Terraform Module

# modules/waf/variables.tf

variable "name" {
  type = string
}

variable "environment" {
  type = string
}

variable "scope" {
  description = "CLOUDFRONT or REGIONAL (for ALB)"
  type        = string
  default     = "CLOUDFRONT"
  # Note: CLOUDFRONT WAF must be in us-east-1 regardless of app region
}

variable "cloudfront_distribution_arn" {
  description = "ARN of CloudFront distribution to protect"
  type        = string
  default     = ""
}

variable "alb_arn" {
  description = "ARN of ALB to protect (for REGIONAL scope)"
  type        = string
  default     = ""
}

variable "rate_limit_per_ip" {
  description = "Max requests per 5 minutes per IP (0 = disabled)"
  type        = number
  default     = 2000
}

variable "api_rate_limit" {
  description = "Stricter rate limit for /api/ paths"
  type        = number
  default     = 200
}

variable "blocked_countries" {
  description = "ISO 3166-1 alpha-2 country codes to block"
  type        = list(string)
  default     = []
}

variable "allowed_countries" {
  description = "If set, only allow traffic from these countries"
  type        = list(string)
  default     = []
}

variable "enable_bot_control" {
  description = "Enable AWS Bot Control managed rule group (adds cost)"
  type        = bool
  default     = true
}

variable "log_group_arn" {
  type    = string
  default = ""
}

variable "sns_alert_arn" {
  type    = string
  default = ""
}

variable "tags" {
  type    = map(string)
  default = {}
}
# modules/waf/main.tf

locals {
  full_name = "${var.name}-${var.environment}"
  common_tags = merge(var.tags, {
    Module      = "waf"
    Environment = var.environment
    ManagedBy   = "terraform"
  })
}

resource "aws_wafv2_web_acl" "main" {
  name        = local.full_name
  description = "WAF for ${local.full_name}"
  scope       = var.scope

  default_action {
    allow {}  # Allow by default; rules below block/count specific patterns
  }

  # ─── Rule 1: AWS Managed – Core Rule Set (OWASP Top 10) ───────────────────

  rule {
    name     = "AWSManagedRulesCommonRuleSet"
    priority = 10

    override_action {
      none {}  # Use managed rules' actions (block/count/challenge)
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesCommonRuleSet"
        vendor_name = "AWS"

        # Override specific rules to count (not block) during tuning period
        rule_action_override {
          name = "SizeRestrictions_BODY"
          action_to_use {
            count {}  # Count first, then switch to block after validating
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "${local.full_name}-common-ruleset"
      sampled_requests_enabled   = true
    }
  }

  # ─── Rule 2: Known Bad Inputs (SQLi, XSS, log4j) ─────────────────────────

  rule {
    name     = "AWSManagedRulesKnownBadInputsRuleSet"
    priority = 20

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesKnownBadInputsRuleSet"
        vendor_name = "AWS"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "${local.full_name}-bad-inputs"
      sampled_requests_enabled   = true
    }
  }

  # ─── Rule 3: SQL Injection Protection ────────────────────────────────────

  rule {
    name     = "AWSManagedRulesSQLiRuleSet"
    priority = 30

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesSQLiRuleSet"
        vendor_name = "AWS"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "${local.full_name}-sqli"
      sampled_requests_enabled   = true
    }
  }

  # ─── Rule 4: IP Reputation List (known malicious IPs) ────────────────────

  rule {
    name     = "AWSManagedRulesAmazonIpReputationList"
    priority = 40

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesAmazonIpReputationList"
        vendor_name = "AWS"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "${local.full_name}-ip-reputation"
      sampled_requests_enabled   = true
    }
  }

  # ─── Rule 5: Bot Control ──────────────────────────────────────────────────

  dynamic "rule" {
    for_each = var.enable_bot_control ? [1] : []
    content {
      name     = "AWSManagedRulesBotControlRuleSet"
      priority = 50

      override_action {
        none {}
      }

      statement {
        managed_rule_group_statement {
          name        = "AWSManagedRulesBotControlRuleSet"
          vendor_name = "AWS"

          managed_rule_group_configs {
            aws_managed_rules_bot_control_rule_set {
              inspection_level = "COMMON"  # TARGETED costs more but blocks more
              enable_machine_learning = true
            }
          }

          # Allow legitimate crawlers: Google, Bing, etc.
          scope_down_statement {
            not_statement {
              statement {
                byte_match_statement {
                  field_to_match { uri_path {} }
                  positional_constraint = "STARTS_WITH"
                  search_string         = "/api/"
                  text_transformation {
                    priority = 0
                    type     = "LOWERCASE"
                  }
                }
              }
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "${local.full_name}-bot-control"
        sampled_requests_enabled   = true
      }
    }
  }

  # ─── Rule 6: Global Rate Limit (per IP) ──────────────────────────────────

  dynamic "rule" {
    for_each = var.rate_limit_per_ip > 0 ? [1] : []
    content {
      name     = "RateLimitPerIP"
      priority = 60

      action {
        block {
          custom_response {
            response_code = 429
            response_header {
              name  = "Retry-After"
              value = "300"
            }
          }
        }
      }

      statement {
        rate_based_statement {
          limit              = var.rate_limit_per_ip
          aggregate_key_type = "IP"
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "${local.full_name}-rate-limit-ip"
        sampled_requests_enabled   = true
      }
    }
  }

  # ─── Rule 7: Stricter API Rate Limit ─────────────────────────────────────

  dynamic "rule" {
    for_each = var.api_rate_limit > 0 ? [1] : []
    content {
      name     = "RateLimitAPI"
      priority = 70

      action {
        block {
          custom_response {
            response_code = 429
            response_header {
              name  = "Retry-After"
              value = "300"
            }
          }
        }
      }

      statement {
        rate_based_statement {
          limit              = var.api_rate_limit
          aggregate_key_type = "IP"

          # Only apply to /api/ paths
          scope_down_statement {
            byte_match_statement {
              field_to_match { uri_path {} }
              positional_constraint = "STARTS_WITH"
              search_string         = "/api/"
              text_transformation {
                priority = 0
                type     = "LOWERCASE"
              }
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "${local.full_name}-rate-limit-api"
        sampled_requests_enabled   = true
      }
    }
  }

  # ─── Rule 8: Geo Restriction (block countries) ───────────────────────────

  dynamic "rule" {
    for_each = length(var.blocked_countries) > 0 ? [1] : []
    content {
      name     = "GeoBlock"
      priority = 80

      action {
        block {}
      }

      statement {
        geo_match_statement {
          country_codes = var.blocked_countries
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "${local.full_name}-geo-block"
        sampled_requests_enabled   = true
      }
    }
  }

  # ─── Rule 9: Geo Allow-List (only allow specific countries) ──────────────

  dynamic "rule" {
    for_each = length(var.allowed_countries) > 0 ? [1] : []
    content {
      name     = "GeoAllowList"
      priority = 85

      action {
        block {}
      }

      statement {
        not_statement {
          statement {
            geo_match_statement {
              country_codes = var.allowed_countries
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "${local.full_name}-geo-allowlist"
        sampled_requests_enabled   = true
      }
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = local.full_name
    sampled_requests_enabled   = true
  }

  tags = local.common_tags
}

# ─── Associate with CloudFront ────────────────────────────────────────────────

resource "aws_wafv2_web_acl_association" "cloudfront" {
  count        = var.cloudfront_distribution_arn != "" ? 1 : 0
  resource_arn = var.cloudfront_distribution_arn
  web_acl_arn  = aws_wafv2_web_acl.main.arn
}

# ─── Associate with ALB ───────────────────────────────────────────────────────

resource "aws_wafv2_web_acl_association" "alb" {
  count        = var.alb_arn != "" ? 1 : 0
  resource_arn = var.alb_arn
  web_acl_arn  = aws_wafv2_web_acl.main.arn
}

# ─── WAF Logging ──────────────────────────────────────────────────────────────

resource "aws_cloudwatch_log_group" "waf" {
  # WAF log group name MUST start with aws-waf-logs-
  name              = "aws-waf-logs-${local.full_name}"
  retention_in_days = 30
  tags              = local.common_tags
}

resource "aws_wafv2_web_acl_logging_configuration" "main" {
  log_destination_configs = [aws_cloudwatch_log_group.waf.arn]
  resource_arn            = aws_wafv2_web_acl.main.arn

  # Redact sensitive headers from logs
  redacted_fields {
    single_header { name = "authorization" }
  }
  redacted_fields {
    single_header { name = "cookie" }
  }
}

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Custom IP Block List

# modules/waf/ip-sets.tf

# Manual IP block list (update via Terraform or AWS CLI)
resource "aws_wafv2_ip_set" "blocked_ips" {
  name               = "${local.full_name}-blocked-ips"
  description        = "Manually blocked IP addresses"
  scope              = var.scope
  ip_address_version = "IPV4"
  addresses          = var.blocked_ip_cidrs  # ["1.2.3.4/32", "5.6.7.8/32"]
  tags               = local.common_tags
}

# Add a rule to the ACL referencing the IP set
# (Add inside the aws_wafv2_web_acl resource at priority 5 — before everything else)
rule {
  name     = "BlockListedIPs"
  priority = 5

  action {
    block {}
  }

  statement {
    ip_set_reference_statement {
      arn = aws_wafv2_ip_set.blocked_ips.arn
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "${local.full_name}-blocked-ips"
    sampled_requests_enabled   = true
  }
}

CloudWatch Alarms for WAF

resource "aws_cloudwatch_metric_alarm" "blocked_requests_spike" {
  count               = var.sns_alert_arn != "" ? 1 : 0
  alarm_name          = "${local.full_name}-waf-blocks-spike"
  alarm_description   = "Spike in WAF blocked requests — possible attack"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "BlockedRequests"
  namespace           = "AWS/WAFV2"
  period              = 300
  statistic           = "Sum"
  threshold           = 100

  dimensions = {
    WebACL = aws_wafv2_web_acl.main.name
    Region = data.aws_region.current.name
    Rule   = "ALL"
  }

  alarm_actions = [var.sns_alert_arn]
  tags          = local.common_tags
}

data "aws_region" "current" {}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Usage Example

# environments/prod/waf.tf

module "waf" {
  source = "../../modules/waf"

  # IMPORTANT: CloudFront WAF must be in us-east-1
  providers = {
    aws = aws.us_east_1
  }

  name        = "myapp"
  environment = "prod"
  scope       = "CLOUDFRONT"

  cloudfront_distribution_arn = module.cdn.distribution_arn

  rate_limit_per_ip = 3000   # ~10 req/sec per IP
  api_rate_limit    = 300    # ~1 req/sec on /api/ paths

  enable_bot_control = true

  # Block countries with high fraud rates (tune for your product)
  blocked_countries = []

  sns_alert_arn = aws_sns_topic.security_alerts.arn

  tags = { Project = "myapp", CostCenter = "security" }
}

Tuning Strategy

Week 1: Count mode only

Start with count {} actions on all managed rules instead of block {}. Review CloudWatch logs to identify false positives before blocking real users.

# Query WAF logs to see what would have been blocked
aws logs start-query \
  --log-group-name "aws-waf-logs-myapp-prod" \
  --start-time $(date -d '1 hour ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'fields @timestamp, httpRequest.uri, terminatingRuleId, action
    | filter action = "BLOCK" or action = "COUNT"
    | stats count() by terminatingRuleId
    | sort count desc'

Week 2: Switch to block, add exceptions

# Add rule exception for legitimate large payloads (e.g., file uploads)
rule_action_override {
  name = "SizeRestrictions_BODY"
  action_to_use {
    allow {}  # Your upload endpoint sends large bodies
  }
}

Cost Estimates (2026, us-east-1)

ComponentMonthly Cost
WAF Web ACL$5.00/month
Per rule (managed groups count as 1 rule each)$1.00/rule/month
Request processing$0.60/million requests
Bot Control (COMMON)$10.00/million requests
Bot Control (TARGETED)$40.00/million requests
Typical SaaS (5M req/month, no bot control)~$20–$30/month
Typical SaaS (5M req/month, bot control COMMON)~$70–$80/month

Engineering cost estimates:

ScopeTimelineEngineering Cost
Basic WAF + OWASP rules0.5–1 day$400–$800
Rate limiting + IP sets0.5–1 day$400–$800
Bot control + geo rules0.5–1 day$400–$800
Full Terraform + logging + alarms1–2 days$800–$1,600
Tuning period (2 weeks monitoring)Ongoing$1,000–$2,000

See Also


Working With Viprasol

We implement AWS WAF configurations for SaaS products handling compliance requirements, DDoS mitigation, and bot protection. Our security team has tuned WAF rules for products serving millions of requests per day across diverse global traffic patterns.

What we deliver:

  • Complete Terraform WAF module with managed rules and custom rate limits
  • Tuning period with CloudWatch log analysis to eliminate false positives
  • Bot control configuration with legitimate crawler allowlists
  • Security incident response runbooks
  • Ongoing WAF rule monitoring and updates

See our cloud infrastructure services or contact us to discuss your security requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.