Skip to content
Security Intermediate Tutorial

Deploy AWS WAF to Block SQLi, XSS, and Bots Before They Hit Your API

Stack AWS Managed Rules and a per-IP rate limit on an ALB, then watch blocks land in CloudWatch.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Sep 2, 2026 · 6 min read
Deploy AWS WAF to Block SQLi, XSS, and Bots Before They Hit Your API

What you'll build

A WAFv2 web ACL that blocks SQL injection, XSS, and bot traffic in front of an existing ALB or API Gateway REST API, plus a per-IP rate limit. You'll fire real attack payloads at it and watch the blocks land in CloudWatch within a minute.

Prerequisites

  • An AWS account with permissions for wafv2, elasticloadbalancing (or apigateway), and cloudwatch.
  • AWS CLI v2, configured with a default region. Commands verified against the current wafv2 CLI reference (September 2026); the wafv2 API hasn't changed shape since launch, so any 2.x build works.
  • An Application Load Balancer or a deployed API Gateway REST API stage in the same region. HTTP APIs (apigatewayv2) can't have a WAF attached; more on that in Troubleshooting.
  • macOS or Linux shell. The two date variants are marked where they differ.
  • Cost: a web ACL runs about $5/month plus per-rule and per-request charges. The Bot Control rule group bills separately (a monthly subscription fee plus a per-request analysis fee); see AWS WAF pricing before you leave this running.

The examples use us-east-1 and an ALB named my-api-alb. Substitute your own.

1. Write the rules file

AWS WAF evaluates rules in priority order, lowest number first. We stack three AWS Managed Rules groups and one custom rate limit. Save this as waf-rules.json:

[
  {
    "Name": "aws-common",
    "Priority": 1,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesCommonRuleSet"
      }
    },
    "OverrideAction": { "None": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "aws-common"
    }
  },
  {
    "Name": "aws-sqli",
    "Priority": 2,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesSQLiRuleSet"
      }
    },
    "OverrideAction": { "None": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "aws-sqli"
    }
  },
  {
    "Name": "aws-bot-control",
    "Priority": 3,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesBotControlRuleSet",
        "ManagedRuleGroupConfigs": [
          {
            "AWSManagedRulesBotControlRuleSet": { "InspectionLevel": "COMMON" }
          }
        ]
      }
    },
    "OverrideAction": { "Count": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "aws-bot-control"
    }
  },
  {
    "Name": "rate-limit-per-ip",
    "Priority": 4,
    "Statement": {
      "RateBasedStatement": {
        "Limit": 300,
        "EvaluationWindowSec": 60,
        "AggregateKeyType": "IP"
      }
    },
    "Action": { "Block": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "rate-limit-per-ip"
    }
  }
]

Why these choices:

  • AWSManagedRulesCommonRuleSet (700 WCU) covers XSS in query args, body, cookies, and URI path, plus LFI, SSRF probes, and oversized payloads.
  • AWSManagedRulesSQLiRuleSet (200 WCU) adds SQLi inspection across query args, body, headers, and cookies.
  • Bot Control (50 WCU) runs at the COMMON inspection level, and it starts in Count mode on purpose. Its SignalNonBrowserUserAgent rule blocks curl, python-requests, and every other HTTP library, which is exactly the traffic a legit API client sends. Count first, read the metrics, then flip "Count" to "None" once you've allowed what matters.
  • The rate rule blocks any single IP that exceeds 300 requests in a 60-second window (EvaluationWindowSec accepts 60, 120, 300, or 600; the minimum Limit is 10).

Total capacity is 952 WCU, under the default 1,500 WCU web ACL cap.

2. Create the web ACL

Default action Allow means anything no rule matches passes through:

aws wafv2 create-web-acl \
  --name api-shield \
  --scope REGIONAL \
  --region us-east-1 \
  --default-action Allow={} \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=api-shield \
  --rules file://waf-rules.json

Grab the ARN for the next steps:

WEB_ACL_ARN=$(aws wafv2 list-web-acls --scope REGIONAL --region us-east-1 \
  --query "WebACLs[?Name=='api-shield'].ARN" --output text)

3. Attach it to your ALB or API Gateway stage

For an ALB:

ALB_ARN=$(aws elbv2 describe-load-balancers --names my-api-alb \
  --query "LoadBalancers[0].LoadBalancerArn" --output text)

aws wafv2 associate-web-acl \
  --web-acl-arn "$WEB_ACL_ARN" \
  --resource-arn "$ALB_ARN" \
  --region us-east-1

For an API Gateway REST API, the resource ARN is the stage, built by hand (note the empty account-id field):

aws wafv2 associate-web-acl \
  --web-acl-arn "$WEB_ACL_ARN" \
  --resource-arn "arn:aws:apigateway:us-east-1::/restapis/<api-id>/stages/prod" \
  --region us-east-1

Both commands return nothing on success. The association takes effect within about a minute.

Verify it works

Send a clean request, then attack payloads:

ALB_DNS=$(aws elbv2 describe-load-balancers --names my-api-alb \
  --query "LoadBalancers[0].DNSName" --output text)

curl -is "http://$ALB_DNS/" | head -1
curl -is "http://$ALB_DNS/?q=<script>alert(1)</script>" | head -1
curl -is "http://$ALB_DNS/search?id=1%27%20OR%20%271%27%3D%271" | head -1

Expected output:

HTTP/1.1 200 OK
HTTP/1.1 403 Forbidden
HTTP/1.1 403 Forbidden

The XSS probe trips CrossSiteScripting_QueryArguments in the common rule set; the SQLi probe trips the SQLi rule group. For the rate limit, hammer the endpoint and count status codes (WAF needs around 30 seconds of sustained traffic before the block kicks in, and enforcement is approximate, not an exact cutoff):

for i in $(seq 1 400); do
  curl -s -o /dev/null -w "%{http_code}\n" "http://$ALB_DNS/"
done | sort | uniq -c

You should see a mix like 312 200 and 88 403. Now pull the block counts from CloudWatch. WAF reports to the AWS/WAFV2 namespace once a minute:

aws cloudwatch get-metric-statistics \
  --namespace AWS/WAFV2 \
  --metric-name BlockedRequests \
  --dimensions Name=WebACL,Value=api-shield Name=Region,Value=us-east-1 Name=Rule,Value=ALL \
  --start-time "$(date -u -d '15 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 300 --statistics Sum --region us-east-1

On macOS, replace the start time with $(date -u -v-15M +%Y-%m-%dT%H:%M:%SZ). Expected shape:

{
    "Label": "BlockedRequests",
    "Datapoints": [
        { "Timestamp": "2026-09-02T14:35:00+00:00", "Sum": 90.0, "Unit": "Count" }
    ]
}

To see the actual blocked requests (WAF keeps a sample of up to 5,000 requests from the last three hours):

aws wafv2 get-sampled-requests \
  --web-acl-arn "$WEB_ACL_ARN" \
  --rule-metric-name aws-sqli \
  --scope REGIONAL \
  --time-window "StartTime=$(date -u -d '15 minutes ago' +%s),EndTime=$(date -u +%s)" \
  --max-items 100 --region us-east-1

Each sampled request shows the client IP, the URI, headers, and "Action": "BLOCK". The CountedRequests metric under the aws-bot-control rule shows what Bot Control would have blocked, including your own curl traffic.

Troubleshooting

WAFInvalidParameterException ... field: SCOPE_VALUE, parameter: CLOUDFRONT on create-web-acl. You passed --scope CLOUDFRONT outside us-east-1. CloudFront-scoped ACLs only exist in us-east-1; for ALB and API Gateway use --scope REGIONAL in the resource's own region.

WAFNonexistentItemException: AWS WAF couldn't perform the operation because your resource doesn't exist on associate-web-acl. Nine times out of ten it's a region mismatch: the web ACL and the target resource must be in the same region, and --region must match both. Also check the ARN character for character; a REST API stage ARN that points at an undeployed stage fails the same way.

Association to an API Gateway HTTP API fails. WAF regional web ACLs attach to REST API stages only. If your ARN contains /apis/ instead of /restapis/, you have an HTTP API and there's nothing to attach to; put a WAF-protected CloudFront distribution or ALB in front, or migrate the API to REST.

Every request returns 403, including legitimate API clients. You flipped Bot Control to enforce mode. Its COMMON level blocks all non-browser user agents. Either keep it in Count for API workloads, or add a scope-down statement so it only inspects browser-facing paths.

Next steps

Turn on WAF logging to CloudWatch Logs or S3 for full request records instead of samples, and build a CloudWatch alarm on BlockedRequests to page you during a real attack wave. When you're ready to enforce Bot Control, study its label system so you can allow verified crawlers while blocking scrapers, and look at the TARGETED inspection level for bots that don't self-identify. If SQLi false positives hit your app (JSON bodies with SQL-looking strings are the classic case), override the specific rule to Count rather than dropping the whole group.

Sources & further reading

  1. AWS Managed Rules rule groups list — docs.aws.amazon.com
  2. Rate-based rule high-level settings in AWS WAF — docs.aws.amazon.com
  3. create-web-acl - AWS CLI Command Reference — docs.aws.amazon.com
  4. associate-web-acl - AWS CLI Command Reference — docs.aws.amazon.com
  5. AWS WAF metrics and dimensions — docs.aws.amazon.com
  6. ManagedRuleGroupConfig - AWS WAFV2 API Reference — docs.aws.amazon.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 5

Join the discussion

Sign in or create an account to comment and vote.

Nina Petrova @night_owl_nina · 23 hours ago

been meaning to test WAF rules against our actual traffic patterns. the cloudwatch logging part sounds useful for catching false positives before they annoy users

Dmitri Sokolov @ai_doomer_dmitri · 1 day ago

setup looks solid for the basics, but I'm curious about false positives—when you're blocking XSS/SQLi at the WAF layer, how do you handle legitimate requests that trigger the managed rules? especially with user-generated content or APIs that intentionally accept structured payloads. are you relying on the AWS sensitivity settings, or do you have a workflow for tuning/exempting specific patterns in production?

Maya Ito @opensource_maya · 1 day ago

solid practical guide. been meaning to lock down our staging env properly—this walk-through makes it less painful than the docs alone

Larry Pike @legacy_larry · 1 day ago

fair, but watch your logs volume once you actually run this in prod—those managed rules are chatty, and if you're high-traffic you'll be paying for CloudWatch ingestion faster than you'd expect. also the per-IP rate limit gets real spicy once you're behind a corporate proxy or cdn and all your traffic looks like it came from three ips.

Sofia Jensen @sofia_jensen · 1 day ago

yeah, the managed rule groups are genuinely less painful than writing regex by hand. just keep an eye on false positives in staging—saw a few teams get bit by overly aggressive bot detection on legitimate monitoring traffic.

Related Reading