Tuning WAF False Positives with Log Mode

After working through this guide you will be able to take a managed ruleset that would break your own application and turn it into one that blocks only attackers — by shipping every rule in log mode first, reading the matched-request sample it produces, separating hostile payloads from your own product’s traffic, and writing exclusions narrow enough that they remove one signature on one route instead of gutting a whole category.

A false positive is not a WAF defect. It is the predictable result of a signature engine meeting an application that legitimately posts HTML, base64, or SQL-shaped strings. The engine cannot know that the <script in a request body came from your own rich-text editor rather than a stored-XSS attempt, so it flags both. Log mode is how you settle that question with evidence instead of guesswork: the rule evaluates, records the match, and lets the request through untouched. The work described here sits directly on top of the rules built in Blocking Common Attacks with Cloudflare WAF Rules — that guide writes them, this one makes them safe to enforce.

Key objectives:

  • Deploy every new managed or custom rule with a non-terminal log action and prove nothing is being blocked.
  • Query the firewall event stream for matched requests and reconstruct enough of each request to judge it.
  • Distinguish an attack from your own application’s traffic using origin, session, and payload shape rather than the signature alone.
  • Replace category-wide disables with exclusions scoped to a rule ID plus a path, method, or single parameter, then promote to block behind a canary with a written rollback trigger.
Log-mode triage funnel A rule deployed in log mode produces a sample of matched events, which is triaged into three outcomes: a real attack that is promoted to block, the application's own traffic that gets a scoped exclusion, and ambiguous traffic that is challenged while logging continues. A match is evidence, not a verdict New rule action = log Matched sample rule id, path, method score, ASN, ray id Hostile payload promote this rule to block Your own application scope an exclusion, keep rule Cannot tell yet challenge and keep logging No rule leaves log mode until every recurring match has a verdict

Prerequisites and environment setup

You need a zone with managed rulesets available, an API token carrying Zone.WAF Edit plus Account Analytics Read (the GraphQL endpoint refuses firewall event queries without the analytics scope), and jq for reading the responses. Terraform is optional but strongly recommended: every override in this guide is a config change you will want to revert in one commit.

export CF_API_TOKEN="cf_xxx_scoped_token"
export CF_ZONE_ID="0123456789abcdef0123456789abcdef"

jq --version                 # 1.6 or newer
terraform version            # >= 1.6, Cloudflare provider >= 4.40

# Confirm the token can read firewall analytics, not just edit rules
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
  -H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.status'

Expected output is "active". One more prerequisite is easy to skip and expensive to skip: turn on payload logging for the zone before you start, otherwise every event you review will tell you which rule matched but not what it matched on, and you will be tuning blind. Payload logging encrypts the matched fragment with a public key you generate, and you decrypt it locally.

Step-by-step tuning procedure

Step 1 — Ship the rule with a non-terminal action

The first deploy of any signature set is an observation deploy. For a managed ruleset, override the action of the whole execute rule to log; for a custom rule, set action = "log" directly. Both are non-terminal, so evaluation continues and the response the user gets is byte-identical to one with no WAF at all.

resource "cloudflare_ruleset" "managed_observe" {
  zone_id = var.zone_id
  name    = "Managed ruleset — observation deploy"
  kind    = "zone"
  phase   = "http_request_firewall_managed"

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

    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee"
      overrides {
        action = "log"          # every rule in the set records instead of blocking
      }
      matched_data {
        public_key = var.payload_log_public_key
      }
    }
  }
}

Apply it, then confirm the observation deploy really is inert. The side effect you want is exactly zero change in status codes:

terraform apply
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://app.example.com/search?q=1%27%20OR%20%271%27%3D%271"

A 200 here is success, not failure — the payload matched a SQL injection signature and was recorded, and the request still reached the origin. If you get 403, an earlier rule in the managed phase is still enforcing and you have not actually reached log mode.

Run the observation deploy for at least one full weekly cycle. Weekday office traffic, a weekend batch job, and a Monday morning marketing send all produce different request shapes, and a rule tuned on three quiet hours will surprise you on the fourth day.

Step 2 — Pull the matched-request sample

The dashboard’s Security Events view is fine for a glance, but tuning needs the GraphQL Analytics API so you can group, sort, and diff. Start with counts per rule so you know which signatures actually fire on your traffic — usually five or six out of several hundred.

curl -s https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{
    "query": "query($zone:String!,$since:Time!,$until:Time!){viewer{zones(filter:{zoneTag:$zone}){firewallEventsAdaptiveGroups(limit:50,filter:{datetime_geq:$since,datetime_lt:$until,action:\"log\"},orderBy:[count_DESC]){count dimensions{ruleId clientRequestPath clientRequestHTTPMethod}}}}}",
    "variables": {
      "zone": "'"$CF_ZONE_ID"'",
      "since": "2026-07-21T00:00:00Z",
      "until": "2026-07-28T00:00:00Z"
    }
  }' | jq -r '.data.viewer.zones[0].firewallEventsAdaptiveGroups[]
      | [.count, .dimensions.ruleId, .dimensions.clientRequestHTTPMethod, .dimensions.clientRequestPath]
      | @tsv'

Expected output is a ranked list, tab separated:

41822   949110  POST    /admin/posts
9130    941100  POST    /admin/posts
6044    942100  GET     /search
88      942190  GET     /wp-login.php
12      921110  GET     /static/../../etc/passwd

Read that list top-down and the tuning work has already sorted itself. Forty thousand matches concentrated on one authenticated admin route is your own editor. Twelve matches on a traversal string aimed at /etc/passwd is somebody probing. Once a rule looks interesting, pull individual events with the decrypted payload:

curl -s https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"query":"query($zone:String!,$since:Time!){viewer{zones(filter:{zoneTag:$zone}){firewallEventsAdaptive(limit:20,filter:{datetime_geq:$since,ruleId:\"941100\"},orderBy:[datetime_DESC]){rayName clientAsn clientCountryName clientRequestPath userAgent metadata{key value}}}}}","variables":{"zone":"'"$CF_ZONE_ID"'","since":"2026-07-27T00:00:00Z"}}' \
  | jq '.data.viewer.zones[0].firewallEventsAdaptive[] | {rayName, clientAsn, clientRequestPath}'

Keep the rayName values. They are the join key between a WAF event and everything else you have — origin access logs, application traces, and any correlation headers you propagate through the edge. Being able to say “ray 8e2a…-IAD was user 4471 saving a blog post at 14:02” is what converts a suspicion into a decision.

Step 3 — Tell an attack apart from your own traffic

Three application patterns generate the overwhelming majority of managed-ruleset false positives, and each has a signature of its own that is easy to recognize once you know it.

A rich-text editor posting HTML. Your CMS body field contains <p>, <a href>, sometimes <iframe> from an embed. XSS signatures fire on every save. The tell: the matches arrive by POST to one authoring route, always from authenticated sessions, always from a small set of ASNs (your customers’ offices), and the matched fragment is well-formed markup rather than an event-handler payload.

A base64 blob in a JSON body. File attachments, signed tokens, and serialized state encoded into JSON routinely contain the substrings select, union, or '-- purely by chance. The tell: the matched fragment sits inside a long unbroken alphanumeric run with = padding, and the same client sends dozens per session without any variation in the surrounding request.

A search box containing SQL keywords. A user on a database-tooling site types select from table into your search field and trips a SQLi rule. The tell: it is a GET, the fragment is human-readable prose, and the same query string never repeats from other clients.

Attacks look different. They arrive as GET requests to paths your application does not serve, from ASNs you never see otherwise, with encoded evasion (%2527, mixed case, comment injection), and the same payload sweeps dozens of paths in seconds. If a match is authenticated, on a route you shipped, and from a session with a normal history, treat it as your own traffic until proven otherwise.

Record the verdicts in a table you keep in the repository next to the Terraform. This is the artifact you will re-read in six months when someone asks why rule 949110 is scoped.

Rule ID Signature Matches/week Verdict Tuning action
949110 Inbound anomaly score exceeded 41,822 Editor saves Scope score rule to /admin/posts
941100 XSS via libinjection 9,130 Editor markup Exclude on POST /admin/posts body
942100 SQLi via libinjection 6,044 Search box Exclude the q parameter only
942190 SQLi detects MSSQL probes 88 Attack Promote to block
921110 HTTP request smuggling 12 Attack Promote to block

The row that trips people up is the first one. Anomaly-scoring rulesets do not block because one signature matched; they block because a per-request score crossed a threshold, and several individually harmless matches can add up to it.

Anomaly score accumulation against the block threshold Three separate low-severity rule matches each add to a request's anomaly score, and only the cumulative total reaches the threshold at which the request is blocked. No single match blocks — the running total does 0 2 4 5 score 2 941100 markup in body score 4 942100 quoted string score 5 920273 odd charset block threshold = 5 Fix the contributors excluding one low-severity match drops the total below

The practical consequence: when rule 949110 (the score rule) dominates your event list, it is not the rule to exclude. Find the two or three low-severity rules feeding it on that route, exclude one of them, and the total falls back under the threshold everywhere else automatically.

Step 4 — Narrow with a scoped exclusion, not a category disable

The temptation at this point is to switch the XSS category off, or add a skip covering /admin/*. Both work, and both hand an attacker a protected-by-nothing surface on the exact route with the most privilege. A correct exclusion is narrow along four axes at once: the rule ID, the path, the method, and where possible the single request field involved.

Broad disable versus scoped exclusion Two panels contrast disabling an entire ruleset, which leaves every path unprotected, with an exclusion pinned to one rule id, one path, one method and one parameter. Scope the exclusion along four axes at once Too broad rule: entire XSS category path: * (whole zone) method: any field: any Result: every route loses the signature, including the ones that never false-positived. Correctly scoped rule: 941100 only path: /admin/posts method: POST field: body.content Result: one signature stops firing on one field of one route. Everywhere else: armed. Widen only when a second false positive proves the narrow scope was wrong

Implement it as an ordered pair of execute rules in the same phase. The first is the narrow one, carrying the overrides; the second is the unmodified ruleset for all other traffic. Because the first terminal match wins within a phase, ordering does the scoping for you.

resource "cloudflare_ruleset" "managed_tuned" {
  zone_id = var.zone_id
  name    = "Managed ruleset with scoped exclusions"
  kind    = "zone"
  phase   = "http_request_firewall_managed"

  # 1. Authoring route: two signatures relaxed, everything else still enforced.
  rules {
    action      = "execute"
    description = "Editor saves — relax markup signatures only"
    enabled     = true
    expression  = "(http.request.uri.path eq \"/admin/posts\" and http.request.method eq \"POST\")"

    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee"
      overrides {
        rules {
          id     = "941100"   # XSS via libinjection
          action = "log"
        }
        rules {
          id      = "942100"  # SQLi via libinjection
          enabled = false
        }
      }
    }
  }

  # 2. Everything else: the ruleset exactly as shipped.
  rules {
    action      = "execute"
    description = "Managed ruleset, unmodified"
    enabled     = true
    expression  = "true"

    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee"
    }
  }
}

Two details are worth internalizing. Setting action = "log" on rule 941100 keeps the signal — you still see the match in analytics and will notice the day its volume triples — whereas enabled = false silences it entirely. Prefer log unless the volume is genuinely drowning you. And an exclusion in the managed phase cannot be replaced by a skip in the custom phase: the managed phase has already run by then, which is the single most common reason a tuning change appears to do nothing.

Step 5 — Promote to block behind a canary

With the false positives excluded, promotion is a controlled rollout, not a switch. Take the rules whose verdict was “attack” first, since they carry the least risk, and leave the tuned ones in log for one more cycle to confirm the exclusion held.

Canary-then-enforce promotion timeline A timeline with four milestones: seven days of log-only observation, exclusions applied, a one percent canary in block mode, then full enforcement, with a rollback trigger noted below. Promotion schedule with a rollback trigger Day 0 to 7 log only, no action Day 7 to 8 exclusions applied Day 8 to 10 block for 1% canary Day 10 block for 100% Revert to log if 403s exceed 0.1% of requests on any tuned path over five minutes

The canary is expressed in the expression itself. Cloudflare exposes a deterministic per-request random value, so you can enforce for a slice of traffic while the rest keeps logging:

  rules {
    action      = "execute"
    description = "Canary: enforce for 1 percent of traffic"
    enabled     = true
    expression  = "(cf.random_seed le 1)"

    action_parameters {
      id = "efb7b8c949ac4650a09736fc376e9aee"
      overrides {
        action = "block"
      }
    }
  }

Write the rollback trigger down before you apply, in the same commit message: if the 403 rate on any tuned path exceeds 0.1% of requests over a five-minute window, or a support ticket describes a failed save or upload, revert the action to log and re-apply. Because rule changes are control-plane config, a revert reaches every point of presence in seconds — far faster than waiting out a DNS TTL — so the correct instinct when a graph moves is to revert first and diagnose afterwards.

Verification

Prove three things: the attack payloads are now blocked, the application payloads are not, and the excluded rule is still logging elsewhere.

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

# 1. Attack signature on a route with no exclusion -> expect 403
curl -s -o /dev/null -w '%{http_code}\n' "$HOST/search?q=%27%20UNION%20SELECT%20NULL--"

# 2. The real editor payload on the tuned route -> expect 200/201
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$HOST/admin/posts" \
  -H 'content-type: application/json' -H "cookie: session=$SESSION" \
  -d '{"title":"Release notes","content":"<p>See <a href=\"/changelog\">changelog</a></p>"}'

# 3. The same markup on an untuned route -> still blocked
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$HOST/api/comments" \
  -H 'content-type: application/json' \
  -d '{"content":"<p>See <a href=\"/changelog\">changelog</a></p>"}'
403
201
403

That third line is the one that tells you the exclusion is scoped rather than global. Then confirm the event stream agrees, by counting actions per rule for the last hour:

curl -s https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer $CF_API_TOKEN" -H 'Content-Type: application/json' \
  --data '{"query":"query($zone:String!,$since:Time!){viewer{zones(filter:{zoneTag:$zone}){firewallEventsAdaptiveGroups(limit:20,filter:{datetime_geq:$since},orderBy:[count_DESC]){count dimensions{ruleId action}}}}}","variables":{"zone":"'"$CF_ZONE_ID"'","since":"2026-07-28T09:00:00Z"}}' \
  | jq -r '.data.viewer.zones[0].firewallEventsAdaptiveGroups[] | "\(.count)\t\(.dimensions.action)\t\(.dimensions.ruleId)"'

You should see block rows for the attack rules and log rows for the tuned ones, with the tuned counts far lower than in Step 2. For continuous checking, ship these events to your own store with Logpush and alert on the block rate per path rather than reading graphs by hand.

Troubleshooting

The anomaly score crosses the threshold from several small matches

Symptom: rule 949110 (or the equivalent score rule) blocks a request, but every individual signature in the event list is low severity and none of them looks wrong on its own. Diagnose by pulling one blocked ray and listing every contributing rule for it, not just the terminal one — the score rule’s event carries the whole set. Fix by excluding the single cheapest contributor on that route so the total falls below the threshold, rather than raising the threshold globally. Raising the threshold weakens every route in the zone; removing one 2-point contributor on one path weakens exactly one path.

The exclusion turned out to be too broad

Symptom: a penetration test or bug bounty report lands an injection through a route you tuned months ago. Diagnose by listing the execute rules and reading their expressions — an expression like starts_with(http.request.uri.path, "/admin") covers far more than the /admin/posts you meant, and enabled = false on a whole category covers more still. Fix by re-anchoring to eq instead of starts_with, adding the method condition, and switching category-level disables back to per-rule log. Then re-run Step 2 for that route: if the narrower scope produces no new false positives in a week, the wide version was never needed.

Log volume drowns the signal

Symptom: the event stream is millions of rows a day, dominated by one noisy rule, and the genuinely interesting matches are invisible. Diagnose with the counts query from Step 2 — if one ruleId is 95% of the volume you have found it. Fix in two moves: exclude that rule on the one route generating the noise so its volume collapses to the interesting cases, and query with orderBy: [count_ASC] for a while, because rare matches are far more likely to be real attacks than common ones. A sampled Logpush job with a filter on ruleId is cheaper than retaining everything at full fidelity.

A rule only fires for authenticated users

Symptom: the rule never matched during your synthetic testing, then produced a burst of false positives the moment it went to block. The cause is almost always that the request bodies which trip it only exist behind login — draft autosaves, file metadata, admin bulk actions — and your unauthenticated probes never generate them. Diagnose by filtering the event sample on requests carrying a session cookie and comparing paths against your authenticated route table. Fix by extending the observation window to cover a full cycle of authenticated activity, and by running your end-to-end test suite against the zone in log mode so the authenticated paths are exercised deliberately. If your edge already performs JWT validation before routing, you can also key the tuning expression on the presence of a verified token, so signatures relax only for requests that carry proven identity.

Frequently Asked Questions

How long should a rule stay in log mode before I enforce it? At minimum one full weekly cycle, because weekday, weekend, and batch traffic produce different request shapes. For rules on authenticated or seasonal routes — invoicing, payroll, month-end exports — wait for that route’s real cycle to occur at least once, even if that means a month.

Is log mode the same as Count mode in AWS WAF? Functionally yes: both are non-terminal actions that record a match and let the request continue. The difference is granularity of the override — Cloudflare applies action = "log" per rule or per category inside an execute rule, while AWS sets OverrideAction: Count on a whole managed rule group or Action: Count on an individual rule.

Should I exclude a rule or lower the paranoia level? Exclude the rule. Lowering the paranoia level removes an entire tier of signatures across every route in the zone to fix one endpoint, which is the widest possible response to the narrowest possible problem. Reserve a paranoia-level change for the case where dozens of unrelated rules at that level all false-positive.

Can I tune a rule without payload logging enabled? You can see which rule matched and on what path, but not the matched fragment, which means you are guessing at whether the content was hostile. Enable encrypted payload logging before the observation deploy — retrofitting it means restarting the observation window because past events cannot be enriched.

Does adding exclusions slow down request processing? No measurably. Rule evaluation happens in the same proxy that already terminated TLS, and an extra execute rule with a path condition costs microseconds. The cost that matters is analytical, not computational: every exclusion is a permanent statement about your threat model that someone has to re-justify later, which is why the verdict table belongs in version control alongside the rules — the same discipline that keeps rate limiting policy reviewable.

Back to WAF & Rate Limiting at the Edge