Chapter 19

Perimeter Security

Scope. This chapter covers the edge: Cloud Armor security policies, WAF rules, IP and geography filtering, rate limiting, Adaptive Protection, DDoS defense, and the security surface of load balancers and Cloud CDN. The data perimeter — VPC Service Controls, access levels, ingress and egress rules — is Chapter 20 and is not pre-explained here. Load balancer architecture is Chapter 7; firewall policies are §5.13 and §5.14. Prerequisites. Chapter 5 (§5.13 firewall rules, §5.14 hierarchical policies), Chapter 7 (§7.6 backend services, §7.7 health checks), Chapter 17 for where the logs go. Verified against. Google Cloud console and API surface as of 2026-09, Cloud SDK 583.0.0, hashicorp/google provider 8.x; see sources at end.

There are two perimeters in Google Cloud and they defend against opposite threats. This chapter is the one that keeps hostile traffic out. Chapter 20 is the one that keeps your data in — a service perimeter stops an authenticated insider or a stolen credential from copying a dataset to somewhere you do not control, and no amount of WAF tuning affects it. Chapters 5, 11, and 12 already defer the exfiltration question to Chapter 20; nothing here substitutes for it.

Chapter 7 introduced Cloud Armor as one component of the delivery path (§7.13§7.16), as much of it as an architecture chapter needs. This chapter is the depth treatment and the book's owner of every control below: where §7.13 says a policy attaches to a backend service, §19.1 says how the policy is designed; where §7.14 names the WAF rule sets, §19.3 says how to tune and stage them. Nothing here contradicts Chapter 7; where the two overlap, this chapter is authoritative.

What this chapter does own is the front door. Every externally reachable service in the reference estate sits behind a global external Application Load Balancer, and that is not merely an architectural preference — it is the only position where Google's edge controls can act. A Cloud Run service reachable at its own run.app URL, or a VM with an external IP, is outside every control described here. The first perimeter decision is therefore not which WAF rules to enable; it is making the load balancer the sole ingress path and proving it by removing every alternative (§19.11).

The second thing worth stating early: Cloud Armor is a policy engine attached to a backend, not a product you turn on. It evaluates an ordered list of rules against each request and takes the first matching action. Everything in this chapter — the OWASP signatures, the geography check, the rate limiter, the adaptive machine-learning defense — is a rule in that list, and the discipline that makes it work is priority allocation and preview mode, not rule count.

19.1 Cloud Armor §

Cloud Armor is Google's edge security policy engine. A security policy is an ordered list of rules attached to a backend service or backend bucket; each request is evaluated against the rules in priority order and the first match decides.

Three policy types exist, and the type is fixed at creation. The SDK's enum is CLOUD_ARMOR, CLOUD_ARMOR_EDGE, and CLOUD_ARMOR_NETWORK:

TypeWhere it actsWhat it can inspect
CLOUD_ARMORBackend security policy, before requests reach the originFull L7: headers, path, body, expressions
CLOUD_ARMOR_EDGEBefore the request is served from Google's cacheA restricted match set; protects cached content and backend buckets
CLOUD_ARMOR_NETWORKNetwork load balancing resources and external-IP instancesL3/L4, attached through a network edge security service

Two tiers, with the current names. Cloud Armor Standard "includes… A pay-as-you go pricing model" and "Always-on protection from Layer 3 and Layer 4 (L3 and L4) volumetric and network protocol-based DDoS attacks." Cloud Armor Enterprise "includes… All the features of Cloud Armor Standard" plus a "Choice of pricing models: Cloud Armor Enterprise Annual or Paygo", and it is what unlocks the capabilities in §19.7 and §19.8.

gcloud compute security-policies create armor-prod-public-edge \
  --type=CLOUD_ARMOR \
  --description="Production public edge policy for rc-saas."

gcloud compute security-policies update armor-prod-public-edge \
  --log-level=VERBOSE \
  --json-parsing=STANDARD

Priority allocation is the design. Rules run 0 to 2147483647, lowest number first, and this book reserves blocks so that a new rule never lands in the wrong place: 1000–1999 allow-listing, 2000–4999 preconfigured WAF, 5000–8999 rate limiting, 9000+ the default rule. Write the block assignment into the policy description.

--log-level=VERBOSE is the setting people forget. At the default level, a blocked request logs the rule that matched; verbose logging adds the matched signature and the offending field, which is the difference between tuning a false positive in an hour and in a week.

Pitfall. A policy is inert until it is attached to a backend. Creating the policy and its rules is one change; setting security_policy on the backend service is another, and a review that approves the first without the second ships nothing.

19.2 WAF Policies §

A WAF rule in Cloud Armor is a rule whose match expression calls evaluatePreconfiguredWaf() against a named signature set, rather than matching on an IP range or header.

The expression language is CEL over a request object. Rules match on request.path, request.headers, request.query, origin.ip, origin.region_code, and — with JSON parsing enabled — the parsed body. That is what makes a Cloud Armor policy composable: a WAF signature set and a custom business rule are the same kind of object.

gcloud compute security-policies rules create 2000 \
  --security-policy=armor-prod-public-edge \
  --action=deny-403 \
  --expression="evaluatePreconfiguredWaf('sqli-v422-stable', {'sensitivity': 1})" \
  --description="OWASP SQL injection signatures, low sensitivity." \
  --preview

--preview is not optional on first deployment. A previewed rule evaluates and logs what it would have done without taking the action. Every WAF rule in this book is deployed previewed, observed for at least a full traffic cycle, and only then enforced by removing the flag with rules update.

Enable JSON parsing for API backends. --json-parsing=STANDARD on the policy makes evaluatePreconfiguredWaf() inspect JSON request bodies; without it, an injection payload inside a JSON field is invisible to every signature. STANDARD_WITH_GRAPHQL extends this to GraphQL payloads.

Per-rule exclusions handle the one field that always breaks. preconfigured_waf_config in Terraform lets a rule exclude a specific request field — a header, a cookie, a query parameter — from a named signature, which is a far smaller hammer than lowering sensitivity across the whole rule.

Pitfall. A deny-403 on a WAF rule returns a body that identifies Cloud Armor. For an API, prefer deny-404 or a deny-502 so a scanner cannot map which payloads your WAF recognizes and which it lets through.

19.3 OWASP Protections §

Cloud Armor ships Google-maintained signature sets derived from the OWASP ModSecurity Core Rule Set. On the current version they carry a v422 version tag.

Each set exists in a -stable and a -canary variant:

Signature setAttack class
sqli-v422-stableSQL injection
xss-v422-stableCross-site scripting
lfi-v422-stable, rfi-v422-stableLocal and remote file inclusion
rce-v422-stableRemote code execution
protocolattack-v422-stableHTTP protocol abuse and request smuggling
scannerdetection-v422-stableAutomated scanner fingerprints
methodenforcement-v422-stableUnexpected HTTP methods
php-v422-stable, java-v422-stableRuntime-specific injection
sessionfixation-v422-stableSession fixation
generic-v422-stableGeneric attack patterns

Sensitivity is the tuning dial and it defaults high. evaluatePreconfiguredWaf() accepts a sensitivity of 0 to 4, described as OWASP paranoia levels, and Google's default is 4 — the most aggressive and the most false-positive-prone. Start every set at sensitivity 1 in preview, and raise it only where the traffic shows it is safe.

opt_out_rule_ids and opt_in_rule_ids are the surgical options, each accepting up to 128 rule IDs. Opting out one signature that fires on a legitimate request is correct; opt_in_rule_ids requires sensitivity: 0 and is how you run a hand-picked subset.

Deploy the sets you can actually service. Four sets tuned and enforced beat twelve sets in permanent preview. This book enables sqli, xss, rce, and protocolattack first, because those four cover the attack classes that turn into a breach rather than a scan.

Pitfall. -canary variants exist so you can test new signature versions ahead of promotion. Running canary in production because it sounded newer is how a signature change nobody reviewed starts denying legitimate traffic.

19.4 IP Filtering §

IP filtering in Cloud Armor is a rule matching --src-ip-ranges, which is evaluated at Google's edge before the request reaches a backend.

It is a coarse control with two legitimate uses, and one common misuse:

  • Allow-listing an administrative or partner surface — the only path to an internal-facing endpoint, at a low priority number so nothing else can override it.
  • Blocking a known-bad range during an incident — fast, reversible, and effective for the hours it takes to fix the actual problem.
  • Not as an authentication mechanism. An IP allow-list is a filter, not an identity, and §21.4 owns authentication.
gcloud compute security-policies rules create 1000 \
  --security-policy=armor-prod-public-edge \
  --action=allow \
  --src-ip-ranges=203.0.113.0/24 \
  --description="Partner integration egress range."

gcloud compute security-policies rules create 9000 \
  --security-policy=armor-prod-public-edge \
  --action=deny-404 \
  --src-ip-ranges="*" \
  --description="Default deny for the admin backend."

A default-deny policy with an allow-list is a completely different posture from a default-allow policy with block rules, and Cloud Armor supports both. Use default-deny for anything with a knowable client population and default-allow with WAF rules for anything public.

The address Cloud Armor matches is the client address as Google's edge sees it. For traffic arriving through another CDN or proxy, that is the proxy, and the real client is in X-Forwarded-For. Use the xff-ip enforcement key (§19.6) or a custom expression rather than --src-ip-ranges in that topology.

Pitfall. IP allow-lists rot. A partner's egress range changes, the rule stays, and eventually the range belongs to someone else. Every allow rule needs a description naming its owner and a review date, because nothing in the platform will tell you the rule became wrong.

19.5 Geo Restrictions §

Cloud Armor resolves the client address to a region code and exposes it as origin.region_code, which a rule expression can match.

gcloud compute security-policies rules create 1500 \
  --security-policy=armor-prod-public-edge \
  --action=deny-404 \
  --expression="origin.region_code == 'XX' || origin.region_code == 'YY'" \
  --description="Jurisdictions with no customers and no support obligation."

Geography blocking is a noise reducer, not a security control. It removes a large volume of untargeted scanning and commodity attack traffic, which makes the remaining logs readable. It stops nobody who is willing to use a VPN, a residential proxy, or a rented VM in a permitted country.

Prefer allow-listing where the business permits it. A service sold only in three markets can allow those region codes and deny the rest, which is a far stronger reduction than enumerating the regions you dislike this quarter.

Geolocation carries a compliance and a correctness cost. Region resolution is best-effort, it misclassifies mobile and satellite users, and blocking a region can conflict with accessibility or non-discrimination obligations. Get the decision recorded by someone who owns that risk rather than making it in a firewall rule.

Pitfall. Geo rules interact badly with health checks and synthetic monitors. A monitoring probe running from an excluded region (§18.5 regions include EUROPE, ASIA_PACIFIC, and others) will report the service down. Allow the probe sources at a lower priority number than the geo deny.

19.6 Rate Limiting §

Cloud Armor rate limiting counts requests per key over an interval and applies an action when the count is exceeded. Two actions implement it: throttle, which rejects excess requests, and rate-based-ban, which blocks the key entirely for a duration once the threshold is passed.

The enforcement key is the design decision. --enforce-on-key-configs accepts all, ip, xff-ip, http-cookie, http-header, http-path, sni, region-code, tls-ja3-fingerprint, tls-ja4-fingerprint, user-ip, and asn.

KeyRight for
ipGeneral abuse control on a public endpoint
xff-ipThe same, when traffic arrives through an upstream proxy or CDN
http-headerPer-API-key or per-tenant limits, keyed on your own header
tls-ja4-fingerprintBot populations that rotate addresses but not TLS stacks
asnBlocking abuse concentrated in one hosting provider's network
gcloud compute security-policies rules create 5000 \
  --security-policy=armor-prod-public-edge \
  --action=rate-based-ban \
  --src-ip-ranges="*" \
  --conform-action=allow \
  --exceed-action=deny-429 \
  --enforce-on-key=ip \
  --rate-limit-threshold-count=600 \
  --rate-limit-threshold-interval-sec=60 \
  --ban-duration-sec=600 \
  --description="600 requests per minute per client IP; 10-minute ban."

Rate limiting at the edge is not API quota. It is a per-client abuse control measured in requests per interval, applied before authentication. Per-consumer entitlement — how many calls a paying customer may make this month — is §21.11 and §21.12, and it belongs where the consumer's identity is known.

Set the threshold from measured traffic, not from a round number. Look at the 99th-percentile request rate per client over a week and set the limit above it. A limit below observed legitimate behavior denies your best customers first, because they are the heaviest users.

Pitfall. --enforce-on-key=ip behind a corporate NAT or a mobile carrier gateway rate-limits thousands of users as one client. For an authenticated API, key on a header carrying the consumer identity; the IP key is for unauthenticated surfaces.

19.7 Adaptive Protection §

Adaptive Protection builds a model of a backend's normal traffic and detects Layer 7 attacks as deviations from it, proposing a rule that would mitigate the specific attack it sees.

The tier boundary is sharp. In Cloud Armor Standard, Adaptive Protection is "Alerting only". Cloud Armor Enterprise provides "Adaptive Protection for Layer 7 endpoints" — which is to say the ability to act on what it finds.

gcloud compute security-policies update armor-prod-public-edge \
  --enable-layer7-ddos-defense \
  --layer7-ddos-defense-rule-visibility=STANDARD

What it produces is an alert plus a candidate rule. The alert appears as a Security Command Center finding (§16.6) and in Cloud Logging, carrying a confidence score, a description of the attack signature it derived, and a suggested Cloud Armor rule expression. Auto-deploy — configured through adaptive_protection_config.auto_deploy_config in Terraform — can apply that rule without a human.

Do not enable auto-deploy first. Run in alerting mode for at least a full seasonal cycle of your traffic, review every proposed rule against what was actually happening, and enable auto-deploy only for backends where a false positive is cheaper than an outage. A promotional launch, a batch import, or a new mobile client release all look like attacks to a model trained on last month.

It needs traffic to be useful. A backend serving a few requests a minute gives the model nothing to learn from, and its findings on such a backend are noise. Enable it on high-volume public endpoints and leave it off elsewhere.

Pitfall. Adaptive Protection detects Layer 7 application attacks. It is not the control for volumetric L3/L4 floods — that is always-on and covered in §19.8 — and teams that enable it expecting DDoS protection have bought the wrong thing.

19.8 DDoS Defense §

Volumetric DDoS defense in Google Cloud is not a product you deploy; it is a property of terminating traffic at Google's edge.

Standard is always on and free of configuration. Cloud Armor Standard provides "Always-on protection from Layer 3 and Layer 4 (L3 and L4) volumetric and network protocol-based DDoS attacks", absorbed by the same infrastructure that fronts Google's own services. Any workload behind a global external load balancer already has this.

Enterprise adds three things worth naming:

CapabilityWhat Google states
Adaptive Protection at L7"Adaptive Protection for Layer 7 endpoints" (§19.7)
DDoS bill protection"requires your project to be enrolled in Cloud Armor Enterprise Annual. It provides credits for future Google Cloud usage for some increases in bills"
DDoS Response support"24/7 help and potential custom mitigations from DDoS attacks from the same team that protects all Google services"

Pass-through load balancers need an explicit object. Proxy load balancers get edge protection by construction; for network load balancing resources and instances with external IPs, protection is attached through a network edge security service. Google's own description: "Network edge security services are used to protect network load balancing resources and instances with external IPs. For example, to add advanced protection for a given region, create a network edge security service in that region and attach a security policy with ADVANCED DDoS protection enabled."

gcloud compute security-policies update armor-net-us-central1 \
  --region=us-central1 \
  --network-ddos-protection=ADVANCED

Judgment. The architecture decision does more than the subscription. A design where every external IP is a load balancer front end, and no VM or managed instance holds one (§8.x), inherits Google's defenses everywhere. A design with a handful of direct-attached external IPs has holes that no tier fixes.

Pitfall. DDoS bill protection is a credit mechanism with eligibility conditions, not an unconditional cap. Budget alerts (§2.5) remain the control that tells you an attack is costing money, and they must exist independently.

19.9 Load Balancer Security §

The load balancer is where the edge controls attach. Chapter 7 owns its architecture; this section covers only the security surface a perimeter design configures.

Four attachment points, all on the backend service or backend bucket:

FieldWhat it attaches
security_policyA CLOUD_ARMOR backend policy, evaluated before the origin
edge_security_policyA CLOUD_ARMOR_EDGE policy, evaluated before the cache
iapIdentity-Aware Proxy, which authenticates before any of this (§4.16, §7.17)
cdn_policyCache behavior, which has its own security consequences (§19.10)
resource "google_compute_backend_service" "app" {
  name                  = "bs-prod-app"
  project               = "rc-saas-prod-app-01"
  protocol              = "HTTPS"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  security_policy       = google_compute_security_policy.public_edge.id

  log_config {
    enable      = true
    sample_rate = 1.0
  }

  backend {
    group = google_compute_region_network_endpoint_group.app.id
  }
}

Turn logging on at the backend service, at full sample rate for anything security-relevant. Cloud Armor decisions are recorded in the load balancer's request logs, and a sample rate below 1.0 means the denied request you are investigating may simply not be there.

Both a backend policy and an edge policy can apply. The edge policy runs first, before cache lookup, so an edge deny never reaches cache or origin. Use the edge policy for coarse blocks (geography, known-bad ranges) and the backend policy for anything needing full L7 inspection.

Pitfall. Attaching a policy to one backend service protects one backend service. A URL map routing five paths to five backend services needs the policy on all five, and the one everyone forgets is the default backend that catches unmatched paths.

19.10 CDN Security §

Cloud CDN caches responses at Google's edge. Every caching decision is also a security decision, because a cache serves one user's response to another.

Three settings decide whether that happens:

  • Cache mode. CACHE_ALL_STATIC caches static content by content type regardless of origin headers; USE_ORIGIN_HEADERS caches only what the origin says is cacheable; FORCE_CACHE_ALL caches everything, including authenticated responses, and is the setting that leaks data.
  • Cache key policy. include_host, include_protocol, and include_query_string, plus header and cookie allow-lists. A key that omits a header the response varies on returns the wrong variant to the wrong client.
  • Negative caching. Caches error responses so an origin failure does not become an origin flood — set explicit TTLs rather than relying on the defaults.

Never use FORCE_CACHE_ALL on a path that can return authenticated content. This is the single highest-severity CDN misconfiguration: one authenticated response cached under a key that omits the session cookie, served to every subsequent visitor.

Signed URLs restrict access to cached objects. A signing key is added to the backend, and requests must carry a valid signature and expiry:

gcloud compute backend-buckets add-signed-url-key bb-prod-assets \
  --key-name=key-2026-q3 \
  --key-file=signing-key.txt

gcloud compute backend-buckets update bb-prod-assets \
  --signed-url-cache-max-age=3600

Rotate signing keys on a schedule and keep two live. Add the new key, wait for issued URLs under the old key to expire, then delete the old one with delete-signed-url-key. The key file itself is a secret and belongs in Secret Manager (§13.7), never in the repository.

Pitfall. --signed-url-cache-max-age sets how long a signed response stays cacheable, and it caps the effective revocation window. A one-day value means an object remains served for a day after you stop issuing URLs for it.

19.11 Origin Protection §

Origin protection means ensuring the load balancer is the only way to reach the backend. Every control in this chapter is bypassed by a request that arrives at the origin directly.

Four things must all be true, and each is verifiable:

  1. No backend has an external IP. Enforced with constraints/compute.vmExternalIpAccess (§2.30), not by convention.
  2. Firewall ingress permits only Google's load balancer and health check ranges. §7.7 owns those ranges and this book's hierarchical policy (§5.14) already allows them; the backend's own rules must permit nothing wider.
  3. Serverless backends are not invokable directly. A Cloud Run service behind a load balancer sets ingress to internal-and-cloud-load-balancing (§10.x), or its own URL remains a complete bypass.
  4. The origin validates that it was fronted. A custom header injected by the load balancer and required by the application, or mutual TLS on the backend connection, so that a leaked backend address is not sufficient.

Cloud Armor does not authenticate the origin connection. It filters what reaches the backend through the load balancer; it has no view of traffic that arrives another way. Origin protection is a firewall and ingress-configuration problem, and treating a WAF as a substitute for it is the most common architectural error in this chapter.

Verify by attacking your own origin. Resolve the backend's address from inside the VPC and from outside it, and confirm the external attempt fails. A quarterly check is enough; the configurations that break this drift in slowly, usually through a debugging change nobody reverted.

Pitfall. A backend bucket is a Cloud Storage bucket, and its own IAM is a second door. An object served through a signed-URL-protected CDN path is still readable by anyone holding roles/storage.objectViewer on the bucket, or by anyone at all if allUsers is bound. §11.10 public access prevention is the control, and it is separate from everything here.

Chapter Summary §

  • This chapter is the ingress perimeter; Chapter 20 is the data perimeter, and neither substitutes for the other.
  • Cloud Armor evaluates an ordered rule list per request and takes the first match; policy type is fixed at creation.
  • The tiers are Cloud Armor Standard and Cloud Armor Enterprise; Adaptive Protection in Standard is alerting only.
  • Reserve priority blocks by rule class, and record the allocation in the policy description.
  • Deploy every WAF rule with --preview first, observe a full traffic cycle, then enforce.
  • Preconfigured signature sets carry a version tag (v422 currently) and exist in -stable and -canary variants; run stable.
  • Sensitivity defaults to 4, the most false-positive-prone setting; start at 1 and raise deliberately.
  • Enable --json-parsing for API backends or injection payloads inside JSON bodies are invisible to every signature.
  • IP allow-lists are filters, not authentication, and they rot silently — every allow rule needs an owner and a review date.
  • Geography blocking reduces noise and stops nobody determined; allow-list where the business permits it.
  • The rate-limit enforcement key is the design: ip for public surfaces, a header for authenticated APIs, xff-ip behind a proxy.
  • Edge rate limiting is abuse control, not per-consumer API quota, which is §21.11 and §21.12.
  • Adaptive Protection needs traffic volume to learn, detects L7 attacks only, and should not auto-deploy on first enablement.
  • Volumetric L3/L4 defense is always on behind a proxy load balancer; pass-through resources need a network edge security service.
  • FORCE_CACHE_ALL on a path that can return authenticated content is the highest-severity CDN misconfiguration available.
  • Signed URL keys are secrets, rotate on a schedule with two live, and --signed-url-cache-max-age caps the revocation window.
  • Origin protection requires no external IPs, narrow firewall ingress, non-bypassable serverless ingress, and an origin that validates it was fronted.

Security Checklist §

ControlWhy it mattersHow to verify (CLI + Console)
A security policy is attached to every externally reachable backendAn unattached policy protects nothinggcloud compute backend-services describe BS --global --format="value(securityPolicy)"
WAF rules enforced, not left in previewA previewed rule logs and allowsgcloud compute security-policies describe POLICY --format="yaml(rules)"
Sensitivity tuned below the default of 4Default sensitivity denies legitimate trafficRule expression contains an explicit sensitivity
--json-parsing enabled for API backendsJSON-bodied injection is otherwise invisiblegcloud compute security-policies describe POLICY
Backend service logging at full sample rateA sampled log may not contain the denied requestBackend service logConfig.sampleRate is 1.0
Rate limit thresholds derived from measured trafficA low limit denies the heaviest legitimate users firstCompare rule threshold to p99 per-client rate
Adaptive Protection alerting reviewed before auto-deployAn untrained model denies a product launchPolicy adaptiveProtectionConfig, and SCC findings
No backend holds an external IPEvery control here is bypassed by a direct connectiongcloud compute instances list --format="table(name,networkInterfaces[].accessConfigs[].natIP)"
Serverless backends set to load-balancer-only ingressThe service's own URL is a complete bypassgcloud run services describe SVC --format="value(metadata.annotations)"
CDN cache mode is not FORCE_CACHE_ALL on dynamic pathsCaches one user's authenticated response for everyoneBackend service cdnPolicy.cacheMode
Signed URL keys rotated and stored in Secret ManagerA leaked key grants access for its full validitygcloud compute backend-buckets describe BB and the secret's version history

Sources §