Reduce false positives and improve AI application availability with optimized Azure Front Door WAF policies
Fixing Legitimate AI Drops: Tuning Azure Front Door
WAF Policies Using Rule Exceptions
A well-tuned WAF and an LLM system prompt want the same thing from opposite directions. The WAF is trained to treat quotes, semicolons, comment sequences, and the word SELECT as evidence of an attack. Your prompt engineering team just shipped a system prompt that contains all four, legitimately, because it's teaching the model to write SQL. Both sides are doing their job correctly. The fix isn't turning the WAF down — it's teaching it exactly where to stop looking.
# A real Azure Front Door WAF log entry, shape preserved, from a blocked
# request carrying an LLM system prompt in the JSON body:
{
"resourceId": "/SUBSCRIPTIONS/.../FRONTDOORWEBAPPLICATIONFIREWALLPOLICIES/PROD-WAF",
"operationName": "Microsoft.Network/FrontDoorWebApplicationFirewallPolicies/Match",
"properties": {
"clientIP": "10.20.0.14",
"requestUri": "https://api.contoso.com/v1/chat/completions",
"ruleName": "Microsoft_DefaultRuleSet-2.1-SQLI-942130",
"policy": "PROD-WAF",
"action": "Block",
"policyMode": "Prevention",
"matchVariableName": "RequestBodyJsonArgValue:messages[0].content",
"matchVariableValue": "...Given a users table with columns id, email,
created_at -- write a query that returns...",
"details": {
"matchedDataType": "SQL_COMMENT_SEQUENCE",
"anomalyScore": 5
}
}
}
# The system prompt is 100% legitimate: it's teaching the model to write
# SQL. The WAF sees the SQL comment token "--" inside a JSON string and
# scores it exactly like it would score a real injection attempt, because
# structurally, to a regex, it IS the same four characters.
# A second, related signature — this time a code-review endpoint:
{
"ruleName": "Microsoft_DefaultRuleSet-2.1-SQLI-942150",
"matchVariableName": "RequestBodyJsonArgValue:code_diff",
"matchVariableValue": "DELETE FROM cache WHERE expires_at < NOW();",
"details": { "matchedDataType": "SQL_INJECTION_LOGIC", "anomalyScore": 5 }
}
# A legitimate code diff, submitted to an AI code-review tool, blocked
# because it CONTAINS a SQL statement as literal text — which is the
# entire point of the endpoint.Symptom: Legitimate requests to an LLM-backed endpoint return 403 Forbidden from Front Door, specifically when the payload contains system prompts about SQL, code samples, or structured data with quotes and semicolons. Failure point: Managed WAF rules in the SQLI rule group pattern-match on token sequences (--, '; DROP, 1=1, keyword clusters) that appear identically in legitimate AI payloads and real attacks — the WAF has no way to distinguish intent from shape. Default platform behaviour: The Default Rule Set (DRS) inspects the full request body, including JSON fields, and scores every match toward a single anomaly threshold. It does not know your endpoint is an LLM API and was never told to treat it differently.
The Default Rule Set that ships with Azure Front Door's WAF was written to catch SQL injection the way it has looked for over a decade: quotes and semicolons chained together, comment sequences that truncate a query, boolean tautologies like 1=1, keyword clusters like UNION SELECT. Every one of those patterns also appears, entirely legitimately, in the payloads modern AI applications send over HTTP — a system prompt instructing a model how to write SQL, a code-review tool submitting a diff that happens to contain a real SQL statement as literal text, a documentation assistant quoting a query from a knowledge base. The WAF cannot tell the difference between an attacker typing 1=1 to break out of a query and a system prompt containing the string "explain why 1=1 is always true" as a teaching example — because at the level the WAF operates, there is no difference. Fixing this is not about weakening the WAF. It's about telling it, with surgical precision, exactly which field on exactly which endpoint is allowed to contain that shape of text — and leaving everything else exactly as suspicious as it was.
Understanding the fix requires understanding the scoring model first, because it's not what most people assume. DRS versions 2.0 and later don't block on the first rule match. They accumulate an anomaly score across every rule that matches anywhere in the request, and only act once that score crosses a threshold.
| Severity | Score contribution | Blocks alone in Prevention mode? |
|---|---|---|
| Critical | 5 | Yes — a single Critical match reaches the threshold by itself |
| Error | 4 | No — needs one more match of any severity to cross 5 |
| Warning | 3 | No — needs another match; two Warnings together clear it |
| Notice | 2 | No — several Notices, or paired with a higher severity, needed |
The threshold itself is fixed at 5. In Prevention mode with the anomaly score action set to Block, any request that accumulates 5 or more is blocked — full stop, regardless of which specific rules contributed. This has a direct, practical consequence for AI payloads: fixing the one rule you can see in the log might not clear the block, because a second, quieter match elsewhere in the same JSON body is still adding to the same score. A system prompt that mentions SQL comment syntax and a boolean tautology example in the same message will trigger two separate rules, and disabling only one leaves the other still contributing toward 5.
In Detection mode, a request that crosses the anomaly threshold is logged but allowed through — useful for understanding what would break before you commit to blocking it. In Prevention mode, the same request is actually rejected. If you're chasing down false positives on a policy already in Prevention, it is worth temporarily testing the same payload in a Detection-mode policy (or a scoped custom rule) to see the full set of rules it would have triggered, not just the first one that happened to appear in the block log.
The SQLI rule group in the Default Rule Set is pattern-matching on token shapes that are genuinely rare in ordinary web traffic — a normal contact form or product search rarely contains the string 1=1 or a SQL comment marker. AI-adjacent endpoints break that assumption completely, and for reasons specific to what they actually do.
| AI payload pattern | What triggers | Why it's legitimate |
|---|---|---|
| System prompts teaching SQL | SQL comment sequences (--, /* */), tautologies (1=1) | A text-to-SQL, data-analyst, or DBA-assistant system prompt necessarily contains SQL syntax as instructional content |
| Code review / diff analysis endpoints | Full SQL statements as literal strings (DELETE FROM, UNION SELECT) | The endpoint's entire purpose is to receive and analyze real code, which may legitimately include SQL |
| RAG grounding data with quotes/semicolons | Chained special characters resembling injection syntax | Retrieved document chunks — logs, config snippets, structured data — carry the same punctuation an attacker would use |
| Long, information-dense JSON bodies | Multiple minor matches accumulating anomaly score | A single large payload has more surface area for any individual token pattern to appear somewhere in it, purely by volume |
If your WAF policy is still on an older DRS version, it may not parse JSON request bodies into structured arguments at all, in which case the false positives you're seeing come from a cruder, whole-body inspection with fewer tuning options available. Confirm your ruleset version first (Microsoft_DefaultRuleSet, version 2.0 or later) — the field-level exclusion techniques in this article depend on the WAF actually parsing messages[0].content as a named JSON argument rather than treating the whole body as one undifferentiated blob.
Before building any exclusion, know its limits — because this is where teams burn hours convinced their exclusion is broken, when actually they've hit a documented gap. Some managed rules evaluate the raw payload of the request body before it's parsed into POST arguments or JSON arguments. When that happens, WAF logs show a matchVariableName of InitialBodyContents or DecodedInitialBodyContents — and Microsoft's documentation is explicit: you cannot currently create exclusions for initial body contents.
Practically, this means: you create a clean, correctly-scoped exclusion for RequestBodyJsonArgValue:messages[0].content, deploy it, and the block persists — because the rule that's actually firing matched on the raw body before the JSON parser ever separated out that field. The log will tell you which situation you're in if you read it carefully.
| matchVariableName in log | Can you exclude it? | What to do instead |
|---|---|---|
| RequestBodyJsonArgValue:field | Yes — standard field-level exclusion | Exclude that named field from the specific rule (Section 6) |
| RequestHeaderNames / Values | Yes | Exclude the specific header name or value pattern |
| PostParamValue:field | Yes | Exclude the named POST argument |
| InitialBodyContents | No — not currently supported | Use a custom Allow rule scoped by URI (Section 7), or disable the specific rule for that endpoint via a scoped custom rule |
| DecodedInitialBodyContents | No — not currently supported | Same as above |
When matchVariableName shows as CookieName, HeaderName, PostParamName, or QueryParamName (rather than ...Value), it means the name of the field triggered the rule — not its contents. Azure's own documentation notes you currently can't create exclusions for cookie names, header names, POST parameter names, or query parameter names in this situation. If your log shows this pattern, the field itself needs renaming, or you fall back to a custom rule.
Architectural Topology: Failing vs Remediated
| Layer | Failing configuration (current) | Remediated configuration (fix) |
|---|---|---|
| Scope of fix | Whole SQLI rule group disabled globally | Single rule, single named field, single endpoint |
| Exclusion granularity | None — or applied at rule-set level (too broad) | Rule-level exclusion on RequestBodyJsonArgValue:messages[*].content |
| Endpoints affected | Every endpoint behind the WAF policy loses SQLI coverage | Only the specific LLM/code endpoint via URI-scoped custom rule |
| Raw-body matches | Ignored or worked around by disabling the whole rule | Identified via log inspection; routed to custom Allow rule instead of a (non-existent) exclusion |
| Rollout | Big-bang switch to Prevention with new exclusions | Detection mode validation window before Prevention |
| Custom rule priority | Not used — relying entirely on managed-rule tuning | Scoped Allow rule evaluated before DRS, bypassing inspection for one exact match |
| Non-AI endpoints | Same reduced protection as the AI endpoint (collateral) | Untouched — full SQLI coverage remains everywhere else |
| Review cadence | Exclusion set once, never revisited | Monthly log review; exclusions re-validated after DRS version bumps |
Every exclusion starts from a log entry, not a guess. Pull the WAF logs for the blocked request and read three fields: which rule fired, what matchVariableName it matched on, and what value triggered it. Those three answer exactly what to exclude and how.
The format of matchVariableName tells you exactly which exclusion match-variable type to use. RequestBodyJsonArgValue:messages[0].content maps to an exclusion with match variable Request body JSON args and a selector of messages[0].content (or a wildcard pattern, depending on your JSON structure). RequestHeaderNames maps to a header-name exclusion. PostParamValue:comment maps to a POST-argument exclusion. There is no translation step — the log is telling you the exact selector to type into the exclusion.
This is the primary tool, and it should be your first attempt for any match that isn't InitialBodyContents. Scope it as tightly as the log allows: a single rule ID, a single named field, nothing broader. Two things stay true after this fix — every other field in the request is still inspected by this rule, and this field is still inspected by every other rule.
Where the log shows a value-level match (e.g. RequestBodyJsonArgValue), prefer excluding by value match variables over name match variables when both would technically work, and always prefer the most specific selector the operator set supports (Equals over StartsWith, StartsWith over Contains). Name-based exclusion by-name variables exist mainly for backward compatibility with older rule set versions; narrower value-based matching leaves less room for an unrelated field that happens to share a name prefix to slip through unexamined.
If your JSON structure has multiple fields that legitimately need the same treatment — messages[*].content and a separate system_prompt field, for example — you can list multiple exclusion selectors in a single exclusion block. They combine as an OR: a match on any one of them causes the WAF to skip evaluation for that specific rule on that specific field. This is more maintainable than five near-identical exclusion resources.
This is the tool for the two cases field exclusions can't reach: an InitialBodyContents match (Section 3), or a payload so consistently triggering across so many rules that per-rule exclusions become an unmaintainable list. Custom rules are always evaluated before the DRS, and a request matching an Allow custom rule bypasses managed-rule inspection entirely for that request — nothing else in the DRS runs against it.
The precision that makes this safe is exactly what the brief asks for: scope the Allow to a specific request URI and a specific header that only your legitimate caller would send — never a bare Allow on a path alone, which any attacker could also reach.
A custom rule that only checks the URI is a hole, not a fix — anyone on the internet can hit that path directly and skip WAF inspection entirely. The header condition is what makes this safe, but only if the header is genuinely unforgeable by an external caller: set it at your backend or via an internal gateway/APIM layer after Front Door, on a network path an external client cannot reach, or authenticate the header's value against a secret the client doesn't have. A header named X-Internal-Service that any client can set in their request is worse than no exclusion at all — it's a documented WAF bypass instruction.
Custom rule priority is evaluated in ascending order — lower numbers run first — and the DRS runs after all custom rules that would otherwise match. If a request matches your Allow rule, no other custom rule and no DRS rule evaluates it at all. This is powerful and therefore dangerous: keep custom Allow rules as narrow as the two-condition example above, and resist the temptation to widen the URI match "just in case" — every character you loosen in that BeginsWith value is attack surface with zero WAF coverage behind it.
Every fix in this article should be developed and validated in Detection mode before it ever touches a Prevention-mode policy in production. Detection mode logs every rule match exactly as Prevention mode would, but lets the request through — which means you can iterate on exclusions and custom rules against real traffic, see exactly what would have been blocked, and confirm your fix actually closes the gap, all without a single legitimate request failing in the meantime.
The gap teams fall into: they build exclusions in Detection mode, confirm the legitimate payload no longer appears in the block log, and ship straight to Prevention. That confirms the false positive is gone — it says nothing about whether the exclusion accidentally also let a real attack payload through. Test both directions in Detection mode: the legitimate payload should stop generating a match; a genuine SQL injection payload aimed at the same field should still generate one. If your exclusion is too broad, the second test is where you'll see it — the log simply won't show the attack payload as matched, and that silence is the warning sign.
Worth flagging before it surprises anyone: when you change the ruleset version on a WAF policy (moving from DRS 2.0 to 2.1, for example), any rule overrides and exclusions you configured against the old version are reset to the defaults for the new version. Treat every ruleset version bump as a mini-migration — re-apply your exclusions against the new version's rule IDs (which can shift), and run the Detection-mode validation cycle again rather than assuming last quarter's tuning carried forward.
Because a WAF block is an urgent, visible incident — a customer-facing endpoint is down — the pressure to fix it fast pushes toward the broadest, quickest lever. These are the ones that trade security for speed without anyone deciding to make that trade explicitly.
| Anti-pattern | Why it feels right | Why it isn't |
|---|---|---|
| Disable the entire SQLI rule group | "It's blocking us, turn it off" | Removes SQL injection protection for every endpoint behind the policy, not just the AI one — including endpoints that actually talk to a database |
| Set the whole policy to Detection mode permanently | "Nothing gets blocked, problem solved" | You've disabled Prevention entirely. Every real attack is now logged and let through, not just the false positives |
| Custom Allow rule on URI alone, no header check | "One less condition, simpler rule" | Anyone who knows or guesses the path bypasses WAF inspection completely — a documented, public bypass |
| Wildcard the exclusion selector broadly (e.g. bare *) | "Covers whatever field triggers next time too" | Excludes far more of the JSON body than the one field that actually needed it — new legitimate-looking attack vectors in adjacent fields go uninspected |
| Exclude at rule-set scope instead of single-rule scope | "One exclusion instead of several" | Removes inspection from that field for every rule in the entire DRS, not just the SQLI rules actually causing problems |
| Ship exclusions straight to Prevention, skip Detection validation | "We're confident, let's just fix it" | The first sign of an over-broad exclusion is a real attack getting through silently — you won't know until it's exploited, because nothing alerts on "successfully bypassed WAF" |
Validation & Verification: Confirm the Fix
Two things must both be true for this fix to be real: the legitimate payload passes, and a genuine attack payload aimed at the same field still gets blocked. Verify both, in that order, before touching Prevention mode.
Three conditions must hold together. One: the exact payload that started the incident now passes, verified by replaying it, not by assumption. Two: a genuine attack payload aimed at the same field is still blocked — proof the exclusion is scoped to the false positive and nothing wider. Three: if a custom Allow rule is involved, its non-URI condition (the header, in this article's example) cannot be set or forged by an external caller — verified by attempting exactly that from outside your trust boundary. Miss any of the three and you either still have the outage, or you've quietly opened a hole that won't announce itself until it's exploited.
Key Takeaways
Frequently Asked Questions
Related FAVRITE Articles
- Azure Front Door Wildcard Revalidation Fix
- Purging the Keys: Migrating Azure OpenAI Applications to Managed Identities and RBAC
- Microsoft Defender for Cloud: A Practical Guide
- The Shared Tenant Noise Performance Drop: Diagnosing Noisy-Neighbor Latency