Home Resume
Homeβ€Ί Blogβ€Ί Week 13 - AWS WAF + Shield Standard: The DDoS Prot…
AWS Weekly Lab AWS Terraform

The DDoS protection you already have, and the layer it cannot see

AWS Shield Standard is free, always on, and has no Terraform resource. It also cannot read a single line of HTTP. This week builds the layer that can β€” two AWS WAF web ACLs across two scopes, deployed in Count mode first, then flipped to Block with the evidence to justify it.

Verified against current vendor documentation on 22 August 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
AWS Platform Engineering Lab Β· Week 13 of 52

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.

A live migration deadline, as of August 2026

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.

AWS Shield console showing Shield Advanced setup incomplete and the L7AM migration banner
Shield Standard is already protecting this account. Shield Advanced is unsubscribed β€” and the banner states the L7AM sunset dates.

Architecture β€” How It Fits Together

Two web ACLs, one at the edge and one at the origin, protecting a deliberately trivial application.

Week 13 architecture: two AWS WAF web ACLs protecting CloudFront and API Gateway A normal user reaches a CloudFront distribution protected by Shield Standard and a CloudFront-scope web ACL, which forwards to an API Gateway REST API protected by a regional-scope web ACL, which invokes a Lambda echo function. A second path bypasses CloudFront and hits API Gateway directly, where the regional web ACL still inspects it. Both web ACLs send logs to CloudWatch, which feeds an alarm and an SNS email. Normal user via the public CloudFront URL Someone with the origin URL skips the edge entirely Shield Standard β€” L3/L4, free, always on no Terraform resource Β· does not read HTTP CloudFront distribution the edge Edge web ACL β€” CLOUDFRONT scope IP set Β· rate limit Β· Core rules Β· Known bad inputs Β· Anti-DDoS API Gateway REST API the origin β€” REST, not HTTP API (WAF cannot attach to HTTP APIs) Origin web ACL β€” REGIONAL scope the same five rules β€” this is what makes the bypass pointless Lambda echo answers if it got through CloudWatch Logs creds redacted Alarms Blocked + Counted SNS β†’ email alarm and recovery bypasses CloudFront entirely β€” still inspected here logs

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.

REST API, not HTTP API β€” this is a hard constraint

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.

PriorityRuleWCUWhat it catches
10Break-glass IP set1Specific attacker addresses, added by hand during an incident
20Rate-based, 60s window2Volumetric abuse of otherwise-legitimate requests
30AWSManagedRulesCommonRuleSet700OWASP-class: XSS, LFI/RFI, SSRF to EC2 metadata, size limits
40AWSManagedRulesKnownBadInputsRuleSet200Log4Shell, Java deserialization RCE, exploitable paths
50AWSManagedRulesAntiDDoSRuleSet50Adaptive 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.

terraform
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.

The trap: Count is expressed differently for custom rules and managed rule groups

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

ActionWhat happens to the requestUse it for
CountAllowed through; the match is recorded in metrics and logsTesting and tuning
BlockRejected at the edge with a 403; never reaches the applicationEnforcement, once trusted
ChallengeA silent JavaScript challenge; real browsers solve it invisiblyBot and DDoS traffic where false positives are expensive
CAPTCHAA visible puzzle the user must solveLast resort β€” it is user-hostile
A monitoring subtlety this creates

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.

HCP Terraform run history for week-13-dev showing five runs including one errored
The run history, including the errored first apply. 43 resources, Terraform 1.15.8.
The first apply failed on an undocumented requirement

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:

terraform
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:

AWS WAF console showing five rules in Count mode and 953 of 5000 WCUs used
All five rules in Count mode. Note the two different expressions β€” "Count" for custom rules, "Override rule group action to count" for managed groups β€” and the 953/5000 WCU badge.

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.

A silent failure worth guarding against

Forgetting aws_wafv2_web_acl_association does not produce an error. The web ACL exists, reports healthy in the console, and inspects precisely nothing.

Associated AWS resources tab showing the API Gateway prod stage
Regional web ACL, associated to the API Gateway stage.
CloudFront distribution Security tab showing WAF enabled with the edge web ACL
CloudFront takes the web ACL as a distribution attribute.

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.

In Count mode, an HTTP status code tells you almost nothing

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.

WAF sampled requests in Count mode with the CountedRequests graph
Sampled requests during the Count phase. Every action reads ALLOW β€” the matches show up in the CountedRequests series, not in the HTTP response.

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.

AWS WAF console showing the same five rules now in Block mode
The same console page after the flip: "Count" becomes "Block", and "Override rule group action to count" becomes "Use rule actions".

Then the identical simulation ran again. Same requests, same endpoints, one variable changed:

Side-by-side terminal output of the attack simulation in Count mode and Block mode
Left: Count mode, everything 200. Right: Block mode, every attack 403 β€” and the benign baseline still 200.
The rate-based rule finally fired

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.

terraform
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" } }
}
The log group name is not a free choice

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:

WAF log record showing Authorization, Cookie and X-Api-Key all REDACTED while other headers remain
All three credential headers come 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.

The bug that would have made every edge alarm useless

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:

SNS email showing the CloudWatch alarm entering ALARM state
Detection: WAF block β†’ metric β†’ alarm β†’ SNS β†’ inbox.
SNS email showing the CloudWatch alarm returning to OK state
Recovery. Without 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

1. The Anti-DDoS rule group rejected both web ACLs

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.

2. Both edge alarms were dead and looked healthy

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.

3. My test script was misreporting its own results

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, cookie and x-api-key are 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-LOGS policy, 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

ComponentMonthly
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
WAF charges are prorated hourly

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.sh

This 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.

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_override rather 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

How was your experience?
Your feedback helps improve this site.
PoorExcellent