DEV Community

Best Practices for Managing AWS WAF with Code


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Better:

app:surface:graphql
app:exception:mobile_v1_encoding
app:abuse:sql_injection_signal
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add the managed rule group.
  2. Override the rules to count.
  3. Observe labels and sampled requests.
  4. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Now every cleanup rule can decide whether that exception matters.

not_statement {
  statement {
    label_match_statement {
      scope = "LABEL"
      key   = "app:exception:mobile_v1_encoding"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create the Terraform skeleton.
  2. Import the existing resource.
  3. Run terraform plan.
  4. Preserve live settings and tags unless the change is intentional.
  5. Repeat until the plan is no-op.
  6. 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>"
}
Enter fullscreen mode Exit fullscreen mode

For classic CLI import:

terraform import aws_wafv2_web_acl.this \
  '<web-acl-id>/<web-acl-name>/<scope>'
Enter fullscreen mode Exit fullscreen mode

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__"
Enter fullscreen mode Exit fullscreen mode

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)
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 CLOUDFRONT scope, 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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)