
AWS WAF looks simple until it becomes important.
At first it is a few managed rule groups, an IP allowlist, maybe a rate limit on /login. Then a real application grows around it. You add exceptions. You tune false positives. You need different behavior for APIs, static assets, GraphQL, login pages, admin paths, mobile clients, partners, scanners, and known noisy endpoints.
If all of that logic lives as a pile of one-off console edits, the Web ACL eventually becomes a place people are afraid to touch.
The fix is not only "put it in Terraform." The better fix is to model AWS WAF like a small policy engine:
- Rules classify traffic.
- Labels carry facts about a request.
- Cleanup rules make final allow/block decisions.
- Terraform owns the shape, review history, and rollout process.
This article is the pattern I use for WAF-as-code when the ACL is expected to live for years, not weeks.
Start with ownership boundaries
The first mistake is putting every WAF object into one giant Terraform state. AWS WAF has several object types with different lifecycles:
- Web ACLs
- Rule groups
- IP sets
- Regex pattern sets
- Logging configuration
- Resource associations
Some are global for CloudFront. Some are regional. Some are shared by many ACLs. Some are application-specific.
A maintainable ownership model makes that visible. For example, you might manage these as separate Terraform states, separate workspaces, or separate pipeline units:
CloudFront Web ACLs
CloudFront IP sets
Regional Web ACLs per AWS region
Regional rule groups per AWS region
Reusable regex pattern sets
The exact directory structure is not important. What matters is that one change does not require planning every WAF object in the account.
For CloudFront-scope WAF resources, remember that AWS WAF uses CLOUDFRONT scope and the API endpoint is in us-east-1. Regional Web ACLs live in the region of the protected resource.
Keep rule bodies reviewable
Terraform HCL is good for resources, tags, providers, and wiring. Deeply nested WAF statements are not pleasant to review in HCL.
For larger ACLs, consider separating stable resource metadata from ordered rule logic. That can be done with YAML, JSON, generated HCL, or a purpose-built generator.
Example metadata shape:
name: example-edge-acl
scope: CLOUDFRONT
default_action: allow
visibility:
cloudwatch_metrics_enabled: true
sampled_requests_enabled: true
metric_name: example-edge-acl
Example rule document:
rules:
- name: classify_login_paths
priority: 100
action: count
labels:
- app:surface:login
statement:
byte_match:
field: uri_path
positional_constraint: STARTS_WITH
search_string: /login
- name: cleanup_rate_limited_login
priority: 9000
action: block
statement:
and:
- label_match:
scope: LABEL
key: app:surface:login
- label_match:
scope: LABEL
key: app:abuse:rate_limited
Whether you render YAML to JSON, generate dynamic HCL, or write the rules directly is an implementation detail. The important part is that reviewers can read the policy as policy.
Labels are the language of a mature ACL
AWS WAF labels are metadata attached to a request when a rule matches. Later rules can match those labels. Labels remain available until the Web ACL evaluation ends.
That means a WAF rule does not have to decide the final fate of a request immediately. It can say:
This request is on a login path.
or:
This request matched a noisy scanner signature.
or:
This request came from a trusted integration IP set.
Then later rules can combine those facts.
AWS also exposes label metrics, which makes labels useful for monitoring, not just enforcement.
Design label namespaces, not random labels
Random labels become a second naming problem. Treat labels like an internal API.
A useful label naming shape is:
<owner>:<namespace>:<specific_fact>
In AWS WAF terms, app:surface: or app:abuse: is a label namespace, and app:surface:login is a specific label. That distinction matters because label match statements can match either one exact label or a namespace.
Examples:
app:surface:login
app:surface:api
app:surface:admin
app:identity:trusted_integration
app:identity:internal_network
app:identity:anonymous
app:abuse:rate_limited
app:abuse:bad_bot
app:abuse:scanner
app:exception:legacy_client
app:exception:known_false_positive
The key is consistency. You want a reviewer to understand the purpose before reading the statement.
Bad:
matched-rule-17
temp_allow
new_api_fix
block_this
Better:
app:surface:graphql
app:exception:mobile_v1_encoding
app:abuse:sql_injection_signal
The label should describe a fact, not the desired action. app:surface:login is better than app:allow_login, because the same fact might be used by allow, count, challenge, and block logic later.
Use count rules for classification
In a label-first ACL, many early rules should use count.
For example, this rule identifies login traffic:
rule {
name = "classify-login-paths"
priority = 100
action {
count {}
}
rule_label {
name = "app:surface:login"
}
statement {
or_statement {
statement {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = "STARTS_WITH"
search_string = "/login"
text_transformation {
priority = 0
type = "NONE"
}
}
}
statement {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = "STARTS_WITH"
search_string = "/oauth2/authorize"
text_transformation {
priority = 0
type = "NONE"
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "classify-login-paths"
sampled_requests_enabled = true
}
}
This rule does not allow or block. It adds a fact to the request.
Later, a rate-based rule might add another fact:
rule {
name = "classify-login-rate-limit"
priority = 200
action {
count {}
}
rule_label {
name = "app:abuse:login_rate_limited"
}
statement {
rate_based_statement {
limit = 300
aggregate_key_type = "IP"
scope_down_statement {
label_match_statement {
scope = "LABEL"
key = "app:surface:login"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "classify-login-rate-limit"
sampled_requests_enabled = true
}
}
Now you have two independent facts:
- The request is for the login surface.
- The request is over the login rate threshold.
That is much easier to reason about than one deeply nested monster rule.
Cleanup rules make the final decision
Cleanup rules are late-priority rules that convert labels into a final action.
Example:
rule {
name = "cleanup-block-rate-limited-login"
priority = 9000
action {
block {}
}
statement {
and_statement {
statement {
label_match_statement {
scope = "LABEL"
key = "app:surface:login"
}
}
statement {
label_match_statement {
scope = "LABEL"
key = "app:abuse:login_rate_limited"
}
}
statement {
not_statement {
statement {
label_match_statement {
scope = "LABEL"
key = "app:identity:internal_network"
}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cleanup-block-rate-limited-login"
sampled_requests_enabled = true
}
}
This reads like a sentence:
Block login traffic that is rate limited unless it is internal network traffic.
That is the point. Cleanup rules should be boring to review.
Example: managed rules without surprise blocking
Managed rule groups are valuable, but turning them on with blocking actions can be a noisy experience.
A safer pattern:
- Add the managed rule group.
- Override the rules to
count. - Observe labels and sampled requests.
- Add cleanup rules for the labels you are ready to enforce.
Sketch:
rule {
name = "aws-managed-common"
priority = 1000
override_action {
count {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "aws-managed-common"
sampled_requests_enabled = true
}
}
Then block only a label namespace that you have reviewed:
rule {
name = "cleanup-block-known-bad-managed-rule-matches"
priority = 9100
action {
block {}
}
statement {
and_statement {
statement {
label_match_statement {
scope = "NAMESPACE"
key = "awswaf:managed:aws:core-rule-set:"
}
}
statement {
not_statement {
statement {
label_match_statement {
scope = "LABEL"
key = "app:exception:known_false_positive"
}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cleanup-block-known-bad-managed-rule-matches"
sampled_requests_enabled = true
}
}
Important detail: for labels from a different context, such as a managed rule group, match the fully qualified label or namespace that AWS WAF reports. Do not guess. Pull it from sampled requests, logs, or the managed rule group metadata.
Example: exception rules as labels
Exceptions are where many Web ACLs become unreadable.
Try this instead:
rule {
name = "classify-known-mobile-client-encoding"
priority = 300
action {
count {}
}
rule_label {
name = "app:exception:mobile_v1_encoding"
}
statement {
and_statement {
statement {
byte_match_statement {
field_to_match {
single_header {
name = "user-agent"
}
}
positional_constraint = "CONTAINS"
search_string = "ExampleClient/1."
text_transformation {
priority = 0
type = "LOWERCASE"
}
}
}
statement {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = "STARTS_WITH"
search_string = "/api/v1/upload"
text_transformation {
priority = 0
type = "NONE"
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "classify-known-mobile-client-encoding"
sampled_requests_enabled = true
}
}
Now every cleanup rule can decide whether that exception matters.
not_statement {
statement {
label_match_statement {
scope = "LABEL"
key = "app:exception:mobile_v1_encoding"
}
}
}
That beats copying the same exception logic into five different block rules.
Example: allow is also a cleanup action
Sometimes the final action is allow.
For example, an integration endpoint might be default-deny:
/integrations/*
You can model that as:
100 classify integration path
110 classify trusted integration source IP
9000 allow integration path + trusted source
9010 block integration path
The last two rules are the cleanup pair:
rule {
name = "cleanup-allow-integration-api-from-trusted-networks"
priority = 9000
action {
allow {}
}
statement {
and_statement {
statement {
label_match_statement {
scope = "LABEL"
key = "app:surface:integration_api"
}
}
statement {
label_match_statement {
scope = "LABEL"
key = "app:identity:trusted_integration"
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cleanup-allow-integration-api-from-trusted-networks"
sampled_requests_enabled = true
}
}
rule {
name = "cleanup-block-integration-api"
priority = 9010
action {
block {}
}
statement {
label_match_statement {
scope = "LABEL"
key = "app:surface:integration_api"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cleanup-block-integration-api"
sampled_requests_enabled = true
}
}
This is explicit. Partner traffic is not "allowed by accident" because a default action happened to be permissive.
Importing existing WAF resources
Many teams start with existing console-managed WAF resources. Do not rewrite them by hand and hope for the best.
Use an import-first workflow:
- Create the Terraform skeleton.
- Import the existing resource.
- Run
terraform plan. - Preserve live settings and tags unless the change is intentional.
- Repeat until the plan is no-op.
- Make one small intentional change.
For Terraform versions that support import blocks, the shape is:
import {
to = aws_wafv2_web_acl.this
id = "<web-acl-id>/<web-acl-name>/<scope>"
}
For classic CLI import:
terraform import aws_wafv2_web_acl.this \
'<web-acl-id>/<web-acl-name>/<scope>'
The exact import ID format varies by resource type. Check the provider docs for each object.
The principle is more important than the command:
First prove Terraform can reproduce the current object. Then change it.
Use placeholders for environment-specific values
WAF policies often need values that should not be hardcoded:
- IP set ARNs
- Regex pattern set ARNs
- SSM parameter values
- Rule group ARNs
- Account-specific prefixes
- Secret header values
- Remote-state outputs
A clean pattern is to keep placeholders in the rule document and render them at plan time:
rules:
- name: classify-cdn-origin-secret
priority: 100
action: count
labels:
- app:identity:edge
statement:
byte_match:
field:
single_header: x-edge-origin-secret
positional_constraint: EXACTLY
search_string: "__EDGE_ORIGIN_SECRET__"
Then replace from Terraform:
data "aws_ssm_parameter" "edge_origin_secret" {
name = "/example/waf/origin-secret"
with_decryption = true
}
locals {
rendered_rules = replace(
file("rules.yaml"),
"__EDGE_ORIGIN_SECRET__",
nonsensitive(data.aws_ssm_parameter.edge_origin_secret.value)
)
}
Do not commit secrets to source control. If the value must be shared between CloudFront and WAF, store it in something like SSM Parameter Store and have both sides read it.
Rule priority is part of the design
Label-based ACLs depend on order.
One useful convention:
0000-0999 emergency rules
1000-1999 request surface classification
2000-2999 identity/source classification
3000-3999 managed rule groups in count mode
4000-4999 abuse signal classification
8000-8999 temporary experiments
9000-9999 cleanup allow/block/challenge rules
This makes reviews easier. A cleanup rule at priority 1500 should look wrong immediately.
A practical review checklist
Before merging a WAF change, ask:
- Does this change affect
CLOUDFRONTscope, regional scope, or both? - Is the Terraform plan no-op except for the intended change?
- Are labels named as facts rather than actions?
- Does every cleanup rule read like a clear sentence?
- Are exceptions modeled once and reused through labels?
- Are secrets and environment-specific values coming from a secure source?
- Are managed rule labels copied from AWS output rather than guessed?
- Is the rollout count-first where false positives are possible?
- Are CloudWatch metrics enabled for the rules that matter?
- Is there a rollback path that does not require console edits?
The mental model
The most useful shift is to stop thinking of AWS WAF as a list of independent firewall rules.
Think of it as a request evaluation pipeline:
request
-> classify surface
-> classify identity/source
-> classify managed-rule signals
-> classify exceptions
-> cleanup decisions
-> default action
Labels are the memory of that pipeline. Cleanup rules are where the policy becomes enforcement.
Once you adopt that shape, AWS WAF gets easier to review, easier to test, and much less scary to change.
References
- AWS WAF labels: https://docs.aws.amazon.com/waf/latest/developerguide/waf-labels.html
- AWS WAF rules that add labels: https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-label-add.html
- AWS WAF rules that match labels: https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-label-match.html
- AWS WAF custom responses: https://docs.aws.amazon.com/waf/latest/developerguide/waf-custom-request-response.html
Top comments (0)