Blocking Common Attacks with Cloudflare WAF Rules

This guide shows you how to stop SQL injection, cross-site scripting, path traversal, malicious bots and credential-stuffing attempts at the Cloudflare edge using a combination of the Cloudflare Managed Ruleset and your own custom expression rules. By the end you will be able to enable managed protection, author block rules by URI, regex, country, ASN and threat score, attach a rate-limiting rule to your login endpoint, and roll everything out safely in Log mode before flipping to Block.

Key objectives:

  • Enable the Cloudflare Managed Ruleset and tune it to your application.
  • Write custom WAF expression rules that block SQLi, XSS, path traversal and abusive ASNs.
  • Add a rate-limiting rule that throttles credential stuffing against /login.
  • Deploy the whole stack as code with cloudflare_ruleset in Terraform, starting in Log mode.
WAF rule evaluation order at the Cloudflare edge An incoming request passes through the managed ruleset phase, then the custom WAF phase, then the rate-limiting phase before reaching the origin, with blocked requests returning 403. Incoming request 1. Managed Ruleset (OWASP) 2. Custom expression rules 3. Rate limiting Origin 403 Block (any phase match) Phase evaluation order (first match wins per phase) Log mode records the match; Block mode terminates with 403

Cloudflare evaluates security rules in distinct phases. The managed ruleset runs first, followed by your custom http_request_firewall_custom rules, then http_ratelimit rules. Understanding this ordering is essential: a skip action in the custom phase can wave a request past later custom rules, but it cannot undo a managed-ruleset block earlier in the chain. This page sits under WAF & Rate Limiting at the Edge, and pairs closely with dedicated guidance on rate limiting API requests at the edge.

Prerequisites and environment setup

You need a zone on a plan that includes WAF custom rules and managed rulesets (Pro includes the Cloudflare Managed Ruleset; custom rule counts and advanced rate limiting scale with Business and Enterprise). You also need an API token scoped to Zone.WAF Edit and Zone.Zone Settings Read for the target zone, plus the zone ID.

Confirm your tooling versions before you start:

terraform version          # >= 1.6
# Cloudflare provider >= 4.40 (the ruleset schema is stable there)
curl --version | head -n1  # any recent build for probing

Export the credentials so both Terraform and ad-hoc API calls can use them:

export CLOUDFLARE_API_TOKEN="cf_xxx_your_scoped_token"
export CF_ZONE_ID="0123456789abcdef0123456789abcdef"

# Sanity check the token and zone
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID" | \
  python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["result"]["name"], d["success"])'

Expected output is your apex domain followed by True. If you see False, the token lacks the zone scope or the zone ID is wrong before you write a single rule.

Step 1: Enable the Cloudflare Managed Ruleset

The Cloudflare Managed Ruleset is a curated, frequently updated set of signatures for the OWASP top categories: SQLi, XSS, command injection, path traversal and known CVE exploit strings. You deploy it by creating an entry-point ruleset in the http_request_firewall_managed phase that executes the managed ruleset.

resource "cloudflare_ruleset" "managed_waf" {
  zone_id     = var.zone_id
  name        = "Cloudflare Managed Ruleset entrypoint"
  description = "Execute Cloudflare's managed WAF signatures"
  kind        = "zone"
  phase       = "http_request_firewall_managed"

  rules {
    action      = "execute"
    description = "Cloudflare Managed Ruleset"
    expression  = "true"
    enabled     = true

    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee" # Cloudflare Managed Ruleset ID
    }
  }
}

The id is the stable, account-agnostic identifier for the Cloudflare Managed Ruleset. Applying this enables every category at its default sensitivity. Side effect: managed signatures begin evaluating immediately on apply. Because false positives are most likely here, the safest first move is to set the override action to log rather than block while you observe traffic, which Step 5 covers.

Step 2: Add custom expression rules for SQLi, XSS and path traversal

Custom rules let you block patterns the managed ruleset might miss, or harden specific endpoints. They live in the http_request_firewall_custom phase. Cloudflare’s expression language exposes fields like http.request.uri, http.request.uri.query, http.user_agent, ip.geoip.country, ip.geoip.asnum and cf.threat_score.

resource "cloudflare_ruleset" "custom_waf" {
  zone_id     = var.zone_id
  name        = "Custom attack blocks"
  description = "Hand-written blocks for injection, traversal and bad actors"
  kind        = "zone"
  phase       = "http_request_firewall_custom"

  rules {
    action      = "block"
    description = "Block SQLi patterns in query string"
    enabled     = true
    expression  = <<-EOT
      (http.request.uri.query contains "union select") or
      (http.request.uri.query contains "' or 1=1") or
      (lower(http.request.uri.query) matches "(select|insert|update|delete).+(from|into|table)")
    EOT
  }

  rules {
    action      = "block"
    description = "Block XSS payloads"
    enabled     = true
    expression  = <<-EOT
      (http.request.uri.query contains "<script") or
      (http.request.uri.query contains "javascript:") or
      (http.request.uri.query matches "(?i)onerror\\s*=")
    EOT
  }

  rules {
    action      = "block"
    description = "Block path traversal"
    enabled     = true
    expression  = "(http.request.uri.path contains \"../\") or (http.request.uri.path contains \"..%2f\")"
  }

  rules {
    action      = "block"
    description = "Block high threat-score and abusive ASNs"
    enabled     = true
    expression  = "(cf.threat_score gt 40) or (ip.geoip.asnum in {14618 16509} and not http.request.uri.path eq \"/health\")"
  }
}

Rules within a phase evaluate top to bottom, and the first terminating action wins. The threat-score field is Cloudflare’s reputation signal; values above 40 represent IPs with a meaningful history of abuse. The ASN block demonstrates targeting traffic from specific networks while exempting a health-check path. If you need to redirect rather than block by region, that logic belongs in blocking or redirecting traffic by country at the edge rather than a flat WAF block.

Step 3: Block bad bots and protect the login path

Credential stuffing combines automated clients with leaked password lists. A first line of defense is identifying obvious bots and challenging or blocking them, while reserving the heavier rate-limit logic for Step 4.

  rules {
    action      = "managed_challenge"
    description = "Challenge likely bots hitting auth endpoints"
    enabled     = true
    expression  = <<-EOT
      (http.request.uri.path eq "/login" or http.request.uri.path eq "/api/auth")
      and (cf.client.bot or cf.bot_management.score lt 30)
      and not cf.bot_management.verified_bot
    EOT
  }

managed_challenge issues a non-interactive proof-of-work or interactive challenge depending on signal quality, so legitimate users rarely notice it while scripted clients fail. Note the explicit verified_bot exclusion: that keeps Googlebot and other known-good crawlers from being challenged.

Step 4: Add a rate-limiting rule for the login endpoint

Even with bot challenges, a slow distributed attack can stay under the radar. A rate-limiting rule caps how many requests a single client may send to /login in a window. It lives in the http_ratelimit phase.

resource "cloudflare_ruleset" "login_ratelimit" {
  zone_id     = var.zone_id
  name        = "Login rate limiting"
  description = "Throttle credential stuffing on auth"
  kind        = "zone"
  phase       = "http_ratelimit"

  rules {
    action      = "block"
    description = "Max 10 login POSTs per IP per minute"
    enabled     = true
    expression  = "(http.request.uri.path eq \"/login\" and http.request.method eq \"POST\")"

    ratelimit {
      characteristics     = ["ip.src", "cf.colo.id"]
      period              = 60
      requests_per_period = 10
      mitigation_timeout  = 600
    }
  }
}

The characteristics define the counter key. Using ip.src plus cf.colo.id keeps counting accurate per data center. When a client exceeds 10 POSTs in 60 seconds it is blocked for mitigation_timeout (600 seconds). For deeper patterns such as token-bucket behavior and per-API-key counters, see the dedicated guide on rate limiting API requests at the edge.

Step 5: Start in Log mode before Block

The single most important operational habit is observing before enforcing. Convert every block action to log for an initial period, watch the WAF event stream for false positives, then promote to block. For custom rules, change the action; for the managed ruleset, set a category override to log.

  # In the managed_waf execute rule, force every category to log first:
    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee"
      overrides {
        action = "log"
      }
    }

Run with logging for at least one full business cycle (24-72 hours covers weekday and weekend traffic). Side effect: requests are recorded in Security Events but never blocked, so production traffic is untouched while you gather evidence. When the events show only genuine attacks, remove the log override and re-apply.

Verification

After terraform apply, probe each rule with curl and confirm a 403 (or challenge) plus a cf-ray you can correlate in the logs.

HOST="https://www.example.com"

# SQLi probe -> expect 403
curl -s -o /dev/null -w "%{http_code}\n" "$HOST/?q=union%20select%20password%20from%20users"

# Path traversal probe -> expect 403
curl -s -o /dev/null -w "%{http_code}\n" "$HOST/static/../../etc/passwd"

# Capture the cf-ray for log correlation
curl -sI "$HOST/?q=union%20select" | grep -i 'cf-ray\|http/'

Expected output for the blocked probes:

403
403
HTTP/2 403
cf-ray: 8e2a1c4f7b9d0a12-IAD

Pull the matching event from the API to confirm which rule fired:

curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/security/events?limit=5" | \
  python3 -c 'import sys,json
for e in json.load(sys.stdin)["result"]:
    print(e["action"], e.get("rule_id","-"), e["matched_data"] if "matched_data" in e else "")'

The action should read block (or log if you are still in observation mode) and the rule_id should match the rule you authored. Drive the rate limit by sending eleven rapid POSTs and confirming the eleventh returns 429 or 403:

for i in $(seq 1 11); do
  curl -s -o /dev/null -w "%{http_code} " -X POST "$HOST/login" -d 'user=x&pass=y'
done; echo

Troubleshooting

False positives blocking legitimate traffic

If real users report 403s, find the offending rule in Security Events and filter by the user’s IP. The most common culprit is an over-broad regex (for example an XSS rule matching the literal string onerror= in a legitimate query parameter). Narrow the expression by anchoring it to specific paths, or add a skip rule above it for the affected route:

  rules {
    action      = "skip"
    description = "Exempt reporting dashboard from XSS rule"
    enabled     = true
    expression  = "(http.request.uri.path eq \"/reports/render\")"
    action_parameters {
      ruleset = "current"
    }
  }

Skip rules not taking effect

skip only bypasses rules in the same phase. A custom-phase skip cannot bypass a managed-ruleset block, because the managed phase already ran. If the managed ruleset is the source, add a managed-ruleset override scoped to that rule ID or category instead of a custom skip. Verify ordering by reading the rule list:

curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/phases/http_request_firewall_custom/entrypoint" \
  | python3 -c 'import sys,json
for r in json.load(sys.stdin)["result"]["rules"]:
    print(r.get("action"), "-", r.get("description"))'

The skip rule must appear above the rule you intend to bypass.

Over-blocking by ASN or country

A broad ip.geoip.asnum in {...} block can catch a cloud provider that also hosts legitimate API consumers or your own monitoring. Confirm before blocking by switching the rule to log, then querying events grouped by ASN. If a network mixes good and bad traffic, replace the blanket block with a cf.threat_score condition or a rate limit so you penalize behavior rather than origin.

Rate-limit rule never triggers

If repeated login attempts never get throttled, the characteristics or expression is too narrow. A common mistake is keying on ip.src plus a header the attacker rotates, which resets the counter every request. Drop the rotating field, and confirm the matching expression actually covers the attacker’s method (many credential-stuffing tools use POST, but some probe with GET first).

Managed ruleset blocks file uploads or rich-text content

Endpoints that accept HTML, markdown or large JSON bodies frequently trip SQLi and XSS signatures. Rather than disabling the category globally, add a managed-ruleset override that sets action = "log" for the specific rule IDs flagged in your events, scoped with an expression to the upload path only. This keeps protection everywhere else while letting genuine content through.

Frequently Asked Questions

Do I need to disable the managed ruleset to use custom rules? No. They run in separate phases and complement each other. The managed ruleset catches broad signature-based attacks while your custom rules handle application-specific paths, ASNs and thresholds.

What is the difference between block and managed_challenge? block terminates the request with a 403 immediately. managed_challenge interposes a challenge that legitimate browsers pass transparently and most bots fail, which is gentler for endpoints where you expect some real users among the noise.

How long should I leave rules in Log mode? At least one full business cycle, typically 24 to 72 hours, so you observe both weekday and weekend traffic patterns before promoting rules to block. Promote category by category rather than all at once.

Why did a request get blocked when curl shows the rule disabled? Check the managed ruleset, not just your custom rules. A managed signature in the earlier http_request_firewall_managed phase can block a request before your custom phase ever evaluates, and a custom skip cannot undo it.

Can I block by country without affecting legitimate users elsewhere? Yes, but a flat country block is blunt. For redirect-on-region or selective handling, model the logic as geo routing rather than a WAF block, and reserve hard country blocks for regions you genuinely never serve.

Back to WAF & Rate Limiting at the Edge