π In This Post
Why β The Problem This Solves
An internet-facing endpoint receives traffic nobody wrote code for. Path scanners looking for /wp-admin and .env. Log4Shell probes in headers. A single IP sending thousands of requests a minute at an endpoint sized for dozens.
The application usually does not break. It degrades, it bills, and it logs nothing useful about who did it. The team finds out from a latency graph, not from a security signal.
The specific gap: there is no layer between the internet and the application that can say "no" before the request costs you compute. Every request that reaches Lambda is a request you pay for, and a request that gets to touch your code.
Why teams do not already have this
- Managed rules cause false positives. Turn AWS's Core rule set on in Block mode and it will reject a legitimate file upload on day one. Teams enable it, get paged, and switch WAF off permanently.
- The pricing is misread. The "$1 per rule" charge is per rule group, not per rule inside it, which cuts both ways when estimating.
- Shield confusion. Teams believe DDoS protection means AWS Shield Advanced at $3,000/month, conclude it is out of budget, and do nothing at all.
That last one is the misconception this week exists to correct.
What You Need to Know β Skills & Tools
WAF looks simple from the console and has four concepts that decide whether a deployment works or quietly does nothing.
Shield Standard vs Shield Advanced
Standard is free, always on, and protects layers 3 and 4. Advanced is $3,000/month on a 1-year commitment. Believing DDoS protection means Advanced is the most common reason teams ship nothing at all.
WAF scopes
A web ACL is either CLOUDFRONT scope or REGIONAL scope, and they are not interchangeable. Edge-only protection is bypassable if the origin stays publicly reachable.
Rule actions
Count observes, Block rejects, Challenge and CAPTCHA interrogate the client. Custom rules use action; managed rule groups use override_action β and they behave differently.
WCUs β WAF's real capacity currency
Every rule costs Web ACL Capacity Units, and 1,500 are included. CommonRuleSet alone is 700. Stacking managed groups without checking the sum is how you land in overage.
Tools used: Terraform via HCP Terraform (VCS-driven), Python on Lambda, and curl plus CloudWatch metrics for the attack simulation.
Shield Standard is not something you deploy
There is no Terraform resource for AWS Shield Standard. There is no console toggle. It is already active on every AWS account at no charge.
It protects CloudFront, RouteΒ 53, Global Accelerator and Elastic Load Balancing against layer 3 and 4 volumetric attacks β SYN floods, UDP reflection. AWS reports mitigating over 99% of infrastructure-layer attacks in under one second.
What Shield Standard does not do is inspect HTTP. An attacker sending well-formed requests at 10,000 per minute, or a single request carrying an SQL injection payload, is invisible to it. That is layer 7, and layer 7 is AWS WAF's job.
AWS is retiring Shield Advanced's automatic application-layer mitigation (L7AM) on 1 January 2027, replacing it with the AWS WAF Anti-DDoS managed rule group. Free metrics and labels began rolling out 27 July 2026; automatic upgrades of eligible web ACLs began 1 October 2026. If you run Shield Advanced today and rely on automatic L7 mitigation, this affects you. The replacement rule group costs the standard $1/month and does not require a Shield Advanced subscription at all.
The screenshot below is this account's Shield console. Note "Status: Incomplete" against every Shield Advanced setup step β this account runs Standard only, which is the entire point. AWS's own banner at the top states the migration dates.
Architecture β How It Fits Together
Two web ACLs, one at the edge and one at the origin, protecting a deliberately trivial application.
Why two web ACLs and not one
An edge-only WAF protects only the requests that actually travel through CloudFront. The API Gateway invoke URL stays publicly reachable β CloudFront does not hide it. Anyone who discovers that URL walks straight past an edge-only firewall.
The regional web ACL is what makes that bypass pointless. The attack simulation later in this post tests both paths for exactly this reason.
AWS WAF cannot be associated with an API Gateway HTTP API at all. The supported resource types are CloudFront, API Gateway REST APIs, Application Load Balancer, AppSync, Cognito user pools, App Runner, Bedrock AgentCore Gateway, Verified Access and Amplify. Choosing an HTTP API here would have made the entire regional half of this design impossible.
Why Lambda for the origin
The origin is an echo function whose only job is to answer "did this request get through?". It runs only when tested, holds no state, and has effectively zero concurrency. A container or an EC2 instance would bill 24/7 to serve a few minutes of use per month. The choice follows the workload's shape, not habit.
The five rules
AWS WAF evaluates rules in ascending priority order and stops at the first terminating action, so the cheapest and most definitive checks run first.
| Priority | Rule | WCU | What it catches |
|---|---|---|---|
| 10 | Break-glass IP set | 1 | Specific attacker addresses, added by hand during an incident |
| 20 | Rate-based, 60s window | 2 | Volumetric abuse of otherwise-legitimate requests |
| 30 | AWSManagedRulesCommonRuleSet | 700 | OWASP-class: XSS, LFI/RFI, SSRF to EC2 metadata, size limits |
| 40 | AWSManagedRulesKnownBadInputsRuleSet | 200 | Log4Shell, Java deserialization RCE, exploitable paths |
| 50 | AWSManagedRulesAntiDDoSRuleSet | 50 | Adaptive L7 DDoS with silent browser challenges |
953 of the 1,500 WCUs included in the base price. This matters: exceed 1,500 and you pay $0.20 per million requests for every additional 500 WCUs. Stacking Core rule set with Bot Control would blow that budget immediately. The console reports the exact figure, and it matched the estimate.
Two choices worth explaining
evaluation_window_sec = 60, not the 300 default
AWS WAF re-checks the request rate roughly every 10 seconds regardless of this setting β the window controls how far back it looks each time. A five-minute lookback averages an attacker's burst across five minutes before it crosses the threshold. A 60-second window treats a burst as a burst.
aggregate_key_type = "IP" is right here and wrong for many real applications
Users behind a shared corporate NAT all present a single source IP, so IP aggregation throttles the entire office together. The fix is CUSTOM_KEYS over a session identifier. The current Terraform provider also supports ASN and JA3/JA4 TLS fingerprints as aggregation keys β both newer than most tutorials will tell you.
rule {
name = "rate-limit-per-ip"
priority = 20
action { block {} } # or count {} during the observation phase
statement {
rate_based_statement {
limit = 100
aggregate_key_type = "IP"
evaluation_window_sec = 60 # 60 | 120 | 300 (default) | 600
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "week13-waf-regional-rate-limit"
sampled_requests_enabled = true
}
}
How We Built It β Step by Step
Count mode first β the discipline that makes WAF stick
Every rule was deployed with its action set to Count. In Count mode WAF inspects the request, records that it would have blocked it, and lets it through anyway.
This is not caution for its own sake. The Core rule set blocks request bodies over 8Β KB and query strings over 2,048 bytes. On a real application, a file upload or a long rich-text field trips that immediately. Count mode is how you find that out before it rejects a paying customer.
Custom rules use action { count {} }. Managed rule groups use override_action { count {} }. Critically, override_action { none {} } does not mean "no action" β it means "use the rule group's own configured actions", which is to say enforce. Misreading that is the easiest way to ship a blocking WAF while believing it is only observing.
The four rule actions
| Action | What happens to the request | Use it for |
|---|---|---|
| Count | Allowed through; the match is recorded in metrics and logs | Testing and tuning |
| Block | Rejected at the edge with a 403; never reaches the application | Enforcement, once trusted |
| Challenge | A silent JavaScript challenge; real browsers solve it invisibly | Bot and DDoS traffic where false positives are expensive |
| CAPTCHA | A visible puzzle the user must solve | Last resort β it is user-hostile |
During the Count phase, BlockedRequests stays flat at zero because nothing is being blocked. An alarm watching only that metric will report "all clear" while rules are matching heavily. This build ships a separate CountedRequests alarm for exactly that reason.
Deploying
State lives in HCP Terraform (workspace week-13-dev, VCS-driven). The first apply failed, and it is worth showing rather than hiding.
Both web ACLs were rejected with WAFInvalidParameterException: AWSManagedRulesAntiDDoSRuleSet managed rule group config must have at least one RegularExpression in ExemptUriRegularExpressions if UsageOfAction for Challenge is ENABLED. The Terraform provider documents exempt_uri_regular_expression as optional. It is not. Neither terraform validate nor terraform plan catches this β only a real apply does.
The fix is to supply at least one exemption pattern, and to guard it with a variable validation so the next person fails at plan time with an explanation instead of part-way through an apply:
variable "anti_ddos_challenge_exempt_uri_regexes" {
type = list(string)
default = ["^/health$"]
validation {
condition = length(var.anti_ddos_challenge_exempt_uri_regexes) > 0
error_message = "At least one exempt URI regex is required: AWS rejects the web ACL if the Anti-DDoS challenge action is ENABLED with an empty exemption list."
}
}
Why the exemption exists at all is worth understanding: the Anti-DDoS challenge is a silent browser challenge. A real browser solves it transparently, but a machine-to-machine caller cannot execute JavaScript. Without an exemption list, a DDoS event would fail those legitimate callers outright.
With that fixed, the apply completed and the console reported exactly the predicted capacity:
The regional web ACL must be attached to the API Gateway stage via a separate association resource. CloudFront is the exception: it takes its web ACL as an attribute on the distribution itself.
Forgetting aws_wafv2_web_acl_association does not produce an error. The web ACL exists, reports healthy in the console, and inspects precisely nothing.
Testing it: the attack simulation
A shell script sends benign-but-rule-matching requests to my own endpoints. Nothing in it is an exploit β the payloads are the well-known signature strings the managed rule groups look for, sent to a Lambda that echoes its input and does nothing else.
Every request returns 200, because nothing is being blocked by design. Worse, some non-200 responses have nothing to do with WAF at all: API Gateway returns 400 for a malformed URI path and 405 for an unsupported method, and CloudFront returns 403 for a method outside its allowed list. My first version of this script read those as WAF blocks and reported the benign baseline request as "blocked as expected". It was wrong. The only real evidence is CountedRequests and BlockedRequests in CloudWatch, or the WAF logs themselves.
One consequence of that discovery changed a test: a path-traversal sequence in the URI path is rejected by API Gateway with a 400 before WAF is ever consulted. To actually exercise the rule, the payload has to go in a query argument.
In Count mode, the origin web ACL recorded 13 matches and the edge 7 β every one an attack payload, and the benign baseline untouched. Zero false positives is the number that decides whether it is safe to enforce.
Flipping to Block
With clean evidence, the rules were switched to enforcing. The repository default stays count_mode = true deliberately, so anyone reusing the module gets Count-first as the safe default; the flip was made as an HCP workspace variable, which is an operational decision rather than a code change.
Then the identical simulation ran again. Same requests, same endpoints, one variable changed:
Across the earlier runs the rate rule never triggered, because a burst of concurrent requests finishes before WAF's next rate check. In Block mode, a 200-request flood produced 8 blocked requests on the origin. This is normal and worth internalising: WAF re-evaluates the rate roughly every 10 seconds, so requests sent before the next evaluation still get through even once you are over the limit. A rate rule is not an instantaneous cap.
Logging, and proving the redaction works
A WAF log records the request it inspected β which means it records credentials unless you tell it not to. Without redaction, the security control becomes the disclosure vector.
resource "aws_wafv2_web_acl_logging_configuration" "this" {
resource_arn = aws_wafv2_web_acl.this.arn
log_destination_configs = [aws_cloudwatch_log_group.waf.arn]
redacted_fields { single_header { name = "authorization" } }
redacted_fields { single_header { name = "cookie" } }
redacted_fields { single_header { name = "x-api-key" } }
}
It must start with aws-waf-logs-, and must live in the same Region and account as the web ACL. Get it wrong and the logging configuration is rejected with an error that does not make the reason obvious.
Configuring redaction and verifying it are different things. I sent a request carrying three fake credentials and then read the logged record back:
REDACTED; every other header is logged in full. The redaction is selective and it works.Alarms: making a block something a human hears about
A web ACL with no alarm is a control you have to remember to go and look at. Two alarms per web ACL β BlockedRequests for enforcement and CountedRequests for the observation phase β both publishing to SNS.
My first version set the CloudWatch Region dimension to the string "Global" for the CloudFront-scope web ACL. That is a plausible-looking guess and it is wrong: CloudFront-scope WAF metrics carry no Region dimension at all β only WebACL and Rule. A dimension set that matches no metric series does not error. The alarm simply sits in INSUFFICIENT_DATA forever while appearing perfectly healthy. Regional metrics do carry Region. One module serving both scopes has to merge the dimension in conditionally.
A related trap in the same family: the Rule dimension uses the rule's visibility_config.metric_name, not its name. Querying by rule name silently returns nothing. Always confirm against aws cloudwatch list-metrics --namespace AWS/WAFV2 before writing an alarm.
To prove the notification path end to end rather than assume it, I drove the alarm deliberately β 80 blocked requests to cross the threshold of 50:
ok_actions you get an alarm and then silence.The recovery email is the half people forget to configure. An operator who receives an alert and never learns the attack stopped has to go and check manually every time.
Verifying it actually works
The first version of the simulation script reported the benign baseline as "blocked as expected", and it was wrong in a way worth naming: an HTTP status is not a WAF verdict.
API Gateway returns 400 for a malformed URI path and 405 for an unsupported method. CloudFront returns 403 for a method outside allowed_methods. None of those involve WAF at all. And in Count mode everything returns 200 by design, so status codes cannot distinguish a match from a miss.
The only real evidence is CountedRequests and BlockedRequests in CloudWatch, or the WAF logs themselves. A related trap: the Rule metric dimension uses the rule's visibility_config.metric_name, not its name, so querying by rule name silently returns nothing.
Two things were therefore proven by reading data rather than responses β the count-versus-block comparison above, and the redaction check, which confirms a request carrying three fake credentials came back REDACTED in all three headers rather than merely showing the configuration was present.
Challenges β What Actually Went Wrong
The provider documents exempt_uri_regular_expression as optional; AWS requires at least one entry whenever the challenge action is enabled. validate and plan both passed. Only the apply caught it.
Lesson: a provider doc marking a field "Optional" is not a live-tested fact.
CloudFront-scope metrics have no Region dimension. An alarm pointed at a non-existent metric series never fires and never errors.
Lesson: a well-formed configuration that plans cleanly can still be completely inert. Verify against the live metric list, not the config.
It read HTTP status as a WAF verdict, called the benign baseline "blocked as expected", and treated API Gateway's 400/405 and CloudFront's 403 as WAF blocks.
Lesson: a test that cannot distinguish the thing it is testing from unrelated failures is worse than no test, because it produces confident wrong answers.
Security β Controls at Every Layer
The interesting security property of this build is that the firewall itself is the control. What follows is what protects the rest of it.
- Defence at both scopes. The edge ACL protects CloudFront; the regional ACL protects the API Gateway stage. That pairing exists specifically because an origin URL that stays publicly reachable makes an edge-only firewall decorative.
- A break-glass IP set evaluated before the managed rule groups, so a legitimate client caught by a false positive can be let through without disabling a rule for everyone.
- Logs are redacted at source.
authorization,cookieandx-api-keyare declared as redacted fields, so credentials never reach the log group rather than being scrubbed afterwards. - A per-web-ACL log resource policy instead of contributing to the shared account-wide
AWSWAF-LOGSpolicy, which has a maximum size that silently breaks logging configuration once enough web ACLs share it. - Count mode as a safety control, not just a testing convenience. Shipping managed rules straight to Block is what causes the outage that gets WAF switched off permanently.
- No static credentials anywhere. Deployment uses HCP Terraform's OIDC dynamic credentials against an AWS role.
Cost
| Component | Monthly |
|---|---|
| 2 web ACLs @ $5 | $10.00 |
| 10 rules @ $1 (5 per ACL Γ 2) | $10.00 |
| WCU overage | $0 β 953 of 1,500 used |
| CloudFront, API Gateway, Lambda, logs | ~$0 at demo volume |
| Shield Standard | $0 β always on |
| If left running | ~$20 |
| Destroyed | $0 |
The $5 per web ACL and $1 per rule are monthly rates, not minimums. Two web ACLs cost roughly 2.7 cents an hour, so building this, testing it thoroughly and destroying it the same day costs well under a dollar. Shield Advanced, by contrast, is $3,000/month on a one-year commitment and cannot be prorated away.
The real risk on this build is not the rate β it is forgetting it exists. Two web ACLs charge whether or not a single request ever arrives, and there is no usage signal to remind you.
Cleanup
The workspace is VCS-connected, which blocks terraform destroy from a CLI checkout but not a destroy queued through HCP β either from the UI (Workspace β Settings β Destruction and Deletion β Queue destroy plan) or the API with is-destroy: true.
./scripts/cleanup.shThis build was destroyed via an API destroy run: 33 resources destroyed, 0 added or changed, and every post-teardown check passed. Total cost for the week came to about $1.40.
The script verifies rather than destroys, and distinguishes three outcomes rather than two β gone, still present, and could not verify β because an expired session returning an empty result otherwise reads as a clean teardown while having checked nothing at all.
Two things linger for opposite reasons, and both are worth checking by hand:
- A web ACL cannot be deleted while still associated with a resource, so an association left behind blocks the delete.
- A CloudFront distribution must be disabled and fully propagated before it can be removed, which takes roughly fifteen minutes and looks like a hang if you are not expecting it.
Left running, this build has a real floor of about $20/month β unlike most weeks in this series, two web ACLs bill whether or not a single request ever arrives.
References
- Week 13 source on GitHub β Terraform modules, Lambda, attack simulation script
- AWS WAF Developer Guide
- Shield Advanced and the Anti-DDoS managed rule group migration
- AWS WAF pricing Β· AWS Shield pricing
Key Takeaways
- Shield Standard is already protecting you and there is nothing to deploy. The gap it leaves is layer 7, and that is WAF's job.
- Deploy managed rules in Count mode first. Going straight to Block is what produces the day-one outage that gets WAF disabled permanently.
- Protect the origin as well as the edge. An invoke URL that stays publicly reachable makes an edge-only web ACL trivial to bypass.
- Check the WCU sum before adding a managed group. 1,500 are included; CommonRuleSet alone is 700.
- An HTTP status is not a WAF verdict. Only CloudWatch metrics or the WAF logs tell you what the firewall actually did.
- WAF pricing is prorated hourly, so a build-test-destroy day costs under a dollar. Do not let a monthly figure narrow the design.
What I'd do differently in production
- Stay in Count mode far longer. Fourteen matches from a synthetic script is a thin sample. Real production traffic over several days is what actually surfaces false positives.
- Use
rule_action_overriderather than dropping a rule group. When Core rule set rejects a legitimate upload, override that single rule to Count β do not disable 700 rules to fix one. - Reconsider the rate aggregation key. IP aggregation punishes shared-NAT users collectively.
- Add a logging filter. WAF logs one record per inspected request; at real traffic volumes the ingestion cost becomes the dominant line item, not the web ACL.
Comments