Home / Security

Cloud Armor Tutorial: WAF Rules, Rate Limiting, and Bot Defense for Your Load Balancer | 2026

Cloud Armor Tutorial: WAF Rules, Rate Limiting, and Bot Defense for Your Load Balancer | 2026

Google Cloud Armor is the WAF and DDoS layer for the external Application Load Balancer — you attach one security policy to your backend service and get OWASP Core Rule Set WAF rules, per-IP rate-based bans, and reCAPTCHA bot defense enforced at Google's edge. This tutorial builds that policy end to end with gcloud and Terraform, including preview-mode tuning so you never block legitimate users.

By Mateusz Chmielewski · Aug 18, 2026 · 15 min read

What Is Google Cloud Armor?

Google Cloud Armor is GCP's web application firewall (WAF) and DDoS mitigation service. It attaches as a security policy to the backend service of your global external Application Load Balancer (or Cloud CDN / proxy load balancers via edge security policies) and inspects every request at Google's edge, before it reaches your backends. A policy is an ordered list of rules — each rule matches traffic with a CEL expression or IP list and applies an action: allow, deny, throttle, rate-based ban, or redirect to reCAPTCHA. On top of custom rules you can enable preconfigured WAF rules built on the OWASP Core Rule Set (sqli, xss, lfi, rce, and more) with a single expression.

Think of Cloud Armor like the security checkpoint at an airport. Every traveler (request) passes through the same screening before reaching the gates (your backends). Some screening is generic — the metal detector and X-ray (OWASP rules catching known attack shapes). Some is targeted — a no-fly list (IP deny rules) and a limit on how many times someone can re-enter the terminal in an hour (rate-based ban). And for the VIP lounge (login page), an extra ID check (reCAPTCHA). Crucially, the checkpoint sits at the airport entrance, not at each gate — attacks are stopped before they consume any of your infrastructure.

ConceptExplanationWhen to use
Security PolicyThe container of rules attached to a backend service; one policy can front many backend services.One per application tier — e.g. `armor-prod` for the public LB, a stricter one for internal-only services.
Rule PriorityRules evaluate lowest-number-first; the default rule (priority 2147483647) catches everything unmatched.Put specific allows/denies low (1000-3000), keep the default rule as your final allow or deny.
Preconfigured WAF RulesGoogle's packaged OWASP CRS 3.3 signatures, invoked as `evaluatePreconfiguredWaf('sqli-v33-stable')` with tunable sensitivity.Always for HTTP workloads — instant coverage for the OWASP Top 10 without writing regexes.
Preview ModeRule evaluates and logs the decision but does not enforce it.Run every new WAF rule in preview for 1-2 weeks to measure false positives before enforcing.
Rate-Based BanAction that counts requests per key (IP, header, cookie) and bans violators for a fixed duration.Brute-force protection on /login, API abuse control, and cheap L7 flood mitigation.
reCAPTCHA IntegrationRule action that redirects suspicious requests to a reCAPTCHA challenge page or scores them via tokens.Bot defense on credential-sensitive paths (login, signup, checkout) without hard-blocking humans.

Why Put Cloud Armor in Front of Your Load Balancer?

The moment you expose a global external HTTPS load balancer to the internet, every backend behind it inherits the internet's background radiation: SQLi and XSS probes on every parameter, credential-stuffing bots hammering /login, scrapers ignoring robots.txt, and L7 floods that autoscalers happily turn into a bigger bill. Framework-level protections are inconsistent across services, and by the time traffic reaches your GKE pods or Cloud Run instances, you are already paying to process the attack.

Cloud Armor moves enforcement to Google's edge: known-bad payloads are rejected with a 403 a few milliseconds from the client, abusive IPs are banned automatically after crossing a rate threshold, and bots get reCAPTCHA challenges — all before a single byte reaches your backends. Because it is one policy evaluated in one place, the controls are uniform across every service behind the load balancer, auditable in Cloud Logging, and versionable in Terraform. Pair it with detective controls like the [GCP CIS benchmark audit script](/tutorial/gcp-cis-benchmark-audit-gcloud-script) to catch config drift, and [GCP organization policies](/tutorial/gcp-organization-policies-defaults-terraform-module) to prevent unsafe load balancer configurations from being created at all.

FeaturethisServicealtAaltB
Deployment modelAttach policy to LB backend service — no infra to runSelf-managed ModSecurity/NGINX sidecar per workloadCloudflare WAF — DNS proxy in front of GCP
OWASP CRS coveragePreconfigured rules, one CEL expression per attack classManual CRS install, tuning, and updatesManaged ruleset, comparable coverage
Rate limitingNative rate-based ban/throttle per IP, header, or cookielimit_req_zone — per-instance, not globalNative, but evaluated at Cloudflare edge
Egress / hairpin trafficNone — inspection happens inside Google's networkNone, but consumes your compute budgetAll traffic hairpins through Cloudflare first
Cost at 10M requests/mo~$13/mo (policy + rules + request fees)Compute + maintenance hoursBusiness plan $200+/mo for WAF + rate limiting
GCP-native integrationCloud Logging, Cloud Monitoring, IAP, SCC findingsDIY log shippingVia API/Logpush, extra glue

Prerequisites

  • GCP project with billing enabled
  • gcloud CLI v450.0+ installed and authenticated
  • An existing global external Application Load Balancer with a backend service (`web-backend` in the examples)
  • `roles/compute.securityAdmin` to manage policies and rules; `roles/recaptchaenterprise.admin` for the bot-defense step
  • Terraform 1.5+ (only for the IaC step)

Step-by-Step Guide

Step 1: Create the Security Policy and Set the Default Rule

Enable the required APIs and create an empty security policy. Every policy ships with a default rule at priority 2147483647 that matches all traffic — we keep it as `allow` so the policy is safe to attach immediately, then layer denies above it. The default rule is the fail-safe every unmatched request hits. Starting from default-allow and adding explicit denies means a misconfigured WAF rule can never take the site down during rollout — you tighten deliberately, not accidentally.

gcloud services enable compute.googleapis.com \
  recaptchaenterprise.googleapis.com

gcloud compute security-policies create armor-prod \
  --description "WAF + rate limiting + bot defense for the prod HTTPS LB"

# Default rule (priority 2147483647): allow anything not matched above
gcloud compute security-policies rules update 2147483647 \
  --security-policy armor-prod \
  --action allow

Step 2: Add Preconfigured OWASP WAF Rules in Preview Mode

Enable the two highest-value preconfigured rulesets — SQL injection and cross-site scripting from OWASP CRS 3.3 — with `--preview` so matches are logged but not blocked. Preconfigured rules give you thousands of battle-tested attack signatures for free, but CRS can false-positive on rich text, JSON payloads, or base64 blobs. Preview mode lets you measure the false-positive rate on real traffic before a single user sees a 403.

# SQLi — OWASP CRS 3.3 stable ruleset, preview only
gcloud compute security-policies rules create 1000 \
  --security-policy armor-prod \
  --expression "evaluatePreconfiguredWaf('sqli-v33-stable')" \
  --action deny-403 \
  --preview \
  --description "OWASP CRS SQLi (preview)"

# XSS
gcloud compute security-policies rules create 1010 \
  --security-policy armor-prod \
  --expression "evaluatePreconfiguredWaf('xss-v33-stable')" \
  --action deny-403 \
  --preview \
  --description "OWASP CRS XSS (preview)"

# Tuning variant: sensitivity 1 = least false positives
# --expression "evaluatePreconfiguredWaf('sqli-v33-stable', {'sensitivity': 1})"

Step 3: Add Per-IP Rate Limiting with an Automatic Ban

Create a rate-based-ban rule: any source IP exceeding 100 requests per 60 seconds is denied with HTTP 429 and banned for 10 minutes. Requests under the threshold pass through (`conform-action allow`). This is your cheapest defense against brute-force logins, scrapers, and low-and-slow L7 floods — no signature needed, just arithmetic at the edge. The automatic ban means abusive clients cost you nothing for the ban duration, not even rule evaluations downstream.

gcloud compute security-policies rules create 2000 \
  --security-policy armor-prod \
  --expression "true" \
  --action rate-based-ban \
  --rate-limit-threshold-count 100 \
  --rate-limit-threshold-interval-sec 60 \
  --ban-duration-sec 600 \
  --conform-action allow \
  --exceed-action deny-429 \
  --enforce-on-key IP \
  --description "Ban IPs above 100 req/min for 10 minutes"

# Stricter variant for the login path only:
# --expression "request.path.matches('/login')" \
# --rate-limit-threshold-count 10 --ban-duration-sec 1800

Step 4: Add reCAPTCHA Bot Defense on Sensitive Paths

Create a reCAPTCHA WAF session-token key for your domain, then add a rule that redirects requests to /login (and similar paths) through a reCAPTCHA challenge when they don't carry a valid token. Rate limiting stops fast bots; reCAPTCHA stops the slow, distributed ones that rotate IPs to stay under thresholds. Gating only the sensitive paths keeps the rest of the site friction-free while credential stuffing becomes economically pointless.

# WAF-enabled reCAPTCHA key (session token feature on Cloud Armor)
gcloud recaptcha keys create armor-session-key \
  --display-name "armor-session-key" \
  --web --domains example.com \
  --integration-type score \
  --waf-feature session-token \
  --waf-service ca

# Challenge anyone hitting /login without a valid token
gcloud compute security-policies rules create 3000 \
  --security-policy armor-prod \
  --expression "request.path.matches('/login')" \
  --action redirect \
  --redirect-type google-recaptcha \
  --redirect-target armor-session-key \
  --description "reCAPTCHA challenge on login"

Step 5: Attach the Policy to the Load Balancer and Enable Verbose Logging

Bind the policy to the backend service of your external HTTPS load balancer and turn on VERBOSE logging so every rule evaluation — including preview decisions — lands in Cloud Logging. A detached policy protects nothing, and without VERBOSE logs you cannot see *why* a request was allowed or denied, which makes preview-mode tuning and incident forensics impossible.

gcloud compute backend-services update web-backend \
  --security-policy armor-prod \
  --global

gcloud compute security-policies update armor-prod \
  --log-level VERBOSE

# Confirm the attachment
gcloud compute backend-services describe web-backend --global \
  --format='value(securityPolicy)'

Step 6: Test the Defenses — SQLi, Flood, and Logs

Fire a SQLi probe and a request burst at the load balancer, then read Cloud Armor's decisions from Cloud Logging to confirm each layer reacts as designed. An untested WAF is a rumor. Sending known-bad traffic in a controlled way proves the ruleset actually evaluates your requests, and teaches you the log schema you'll query during a real incident.

# 1) SQLi probe — expect 403 once rule 1000 leaves preview mode
curl -i "https://$LB_IP/search?q=%27%20OR%20%271%27%3D%271"

# 2) Burst 150 requests — expect a tail of 429s from rule 2000
for i in $(seq 1 150); do
  curl -s -o /dev/null -w "%{http_code}\n" "https://$LB_IP/"
done | sort | uniq -c

# 3) Read the enforcement decisions
gcloud logging read \
  'resource.type="http_load_balancer"
   AND jsonPayload.enforcedSecurityPolicy.name="armor-prod"' \
  --limit 20 \
  --format='table(httpRequest.requestUrl,
                 jsonPayload.enforcedSecurityPolicy.priority,
                 jsonPayload.enforcedSecurityPolicy.outcome)'

Step 7: Codify the Whole Policy in Terraform

Translate the policy, rules, and backend-service attachment into Terraform so the WAF is reviewable in PRs, reproducible across environments, and immune to console drift. Security controls managed by hand drift silently. In Terraform, a weakened rule or a deleted rate limit shows up as a plan diff and can be gated by review — the same discipline you apply to application code.

resource "google_compute_security_policy" "armor_prod" {
  name = "armor-prod"

  rule {
    action   = "deny(403)"
    priority = 1000
    preview  = true
    match {
      expr {
        expression = "evaluatePreconfiguredWaf('sqli-v33-stable')"
      }
    }
  }

  rule {
    action   = "rate_based_ban"
    priority = 2000
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = ["*"] }
    }
    rate_limit_options {
      conform_action   = "allow"
      exceed_action    = "deny(429)"
      enforce_on_key   = "IP"
      ban_duration_sec = 600
      rate_limit_threshold {
        count        = 100
        interval_sec = 60
      }
    }
  }

  rule {
    action   = "allow"
    priority = 2147483647
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = ["*"] }
    }
  }
}

resource "google_compute_backend_service" "web" {
  name            = "web-backend"
  security_policy = google_compute_security_policy.armor_prod.id
}

Verification & Health Check

Best Practices

  • Preview Before You Enforce
  • Default-Allow, Explicit Deny (for public sites)
  • Rate-Limit the Right Key
  • Log VERBOSE from Day One

Common Mistakes

  • {"errorCode":"PREVIEW_LEFT_ON","symptoms":"Attack probes return 200 even though deny rules exist; logs show `outcome: ACCEPT` with `configuredAction: DENY`.","rootCause":"Rules were created with `--preview` for tuning and never switched to enforce mode — the most common Cloud Armor misconfiguration by far.","fixCommand":"gcloud compute security-policies rules update 1000 \\\n --security-policy armor-prod \\\n --action deny-403\n","code":"resource \"google_compute_security_policy\" \"armor_prod\" {\n rule {\n action = \"deny(403)\"\n priority = 1000\n preview = false # enforce after tuning\n match { expr { expression = \"evaluatePreconfiguredWaf('sqli-v33-stable')\" } }\n }\n}\n","language":"hcl","filename":"fix-preview.tf","prevention":"Alert on `preview: true` rules older than your tuning window, and track enforcement as an explicit Terraform variable per environment."}
  • {"errorCode":"RATE_LIMIT_NEVER_TRIGGERS","symptoms":"Flood traffic sails through; no 429s, no bans, no log entries for the rate-limit rule.","rootCause":"Missing `--exceed-action` / `--ban-duration-sec` on a `rate-based-ban`, or a higher-priority allow rule (e.g. an office-IP allowlist at priority 500) matching the attack traffic first.","fixCommand":"gcloud compute security-policies rules describe 2000 \\\n --security-policy armor-prod \\\n --format='yaml(rateLimitOptions)'\n","code":"gcloud compute security-policies rules update 2000 \\\n --security-policy armor-prod \\\n --exceed-action deny-429 \\\n --ban-duration-sec 600\n","language":"bash","filename":"fix-ratelimit.sh","prevention":"Describe every rule after creation and assert the full rate-limit option set in a CI check; remember rules evaluate lowest-priority-number first."}
  • {"errorCode":"HEALTH_CHECKS_BLOCKED","symptoms":"Backends flip to UNHEALTHY minutes after a default-deny or aggressive deny rule rolls out.","rootCause":"Google health-check probers (35.191.0.0/16 and 130.211.0.0/22) hit the same policy and get denied.","fixCommand":"gcloud compute security-policies rules create 500 \\\n --security-policy armor-prod \\\n --src-ip-ranges \"35.191.0.0/16,130.211.0.0/22\" \\\n --action allow \\\n --description \"Allow Google health check probers\"\n","code":"rule {\n action = \"allow\"\n priority = 500\n match {\n versioned_expr = \"SRC_IPS_V1\"\n config { src_ip_ranges = [\"35.191.0.0/16\", \"130.211.0.0/22\"] }\n }\n}\n","language":"hcl","filename":"fix-healthchecks.tf","prevention":"Bake the prober allowlist into the Terraform module so every policy gets it automatically at a priority below all denies."}
  • {"errorCode":"FALSE_POSITIVE_CRS","symptoms":"Legitimate users get 403s on form submissions containing code snippets, base64, or rich text after WAF enforcement.","rootCause":"CRS sensitivity defaults to the most aggressive level; specific rule IDs (e.g. SQLi comment-sequence detectors) match benign content in request bodies.","fixCommand":"gcloud logging read \\\n 'jsonPayload.enforcedSecurityPolicy.name=\"armor-prod\"\n AND jsonPayload.enforcedSecurityPolicy.outcome=\"DENY\"' \\\n --format='value(jsonPayload.enforcedSecurityPolicy.preconfiguredExprIds)' \\\n --limit 50 | sort | uniq -c | sort -rn\n","code":"# Exclude only the noisy rule IDs, keep the rest of the ruleset\n--expression \"evaluatePreconfiguredWaf('sqli-v33-stable', {'sensitivity': 2, 'opt_out_rule_ids': ['owasp-crs-v030001-id942140-sqli']})\"\n","language":"bash","filename":"fix-false-positives.sh","prevention":"Opt out per rule ID or lower sensitivity — never disable the whole ruleset for one noisy detector; re-run the preview cycle after every tuning change."}
  • {"errorCode":"RECAPTCHA_KEY_REJECTED","symptoms":"`INVALID_ARGUMENT: Invalid redirect target` when creating the redirect rule.","rootCause":"The key was created as a regular reCAPTCHA web key; Cloud Armor redirect targets must be WAF-enabled keys (`--waf-feature` + `--waf-service ca`).","fixCommand":"gcloud recaptcha keys create armor-session-key \\\n --display-name armor-session-key \\\n --web --domains example.com \\\n --integration-type score \\\n --waf-feature session-token \\\n --waf-service ca\n","code":"","language":"bash","filename":"","prevention":"Create WAF keys with gcloud/IaC in the same change as the rule that references them, and verify `--domains` covers every hostname the load balancer serves."}

Cost Analysis

Featuremetriccost1kcost10kcost100kcost1m
Security policy base fee$5.00/mo$5.00/mo$5.00/mo$5.00/mo
Rule fees (6 rules × ~$1)$6.00/mo$6.00/mo$6.00/mo$6.00/mo
Request processing (~$0.75 / 1M)$0.00/mo$0.01/mo$0.08/mo$0.75/mo
reCAPTCHA Enterprise assessments$0.00$0.00usage-basedusage-based
Cloud Armor Enterprise (optional, adds Adaptive Protection + DDoS support)~$3,000/mo~$3,000/mo~$3,000/mo~$3,000/mo

References

Browse all tutorials