Back in 2019, a fintech customer of ours was getting drained through an API endpoint that looked perfectly clean. No SQLi in the URL, no XSS in the headers, nothing our regexes flagged. The attack was tucked inside a nested JSON field three levels down - a serialized string that decoded into a classic injection payload. That incident forced us to rethink how our edge handles bodies, not just headers. If you're only inspecting what’s visible in the request line, you're missing where modern attacks actually live.
Why Body Parsing Became Non-Negotiable for Payload Inspection
For years, WAFs got away with shallow inspection. You looked at URI, headers, maybe a few POST parameters, and you could catch 90% of what OWASP threw at you. That model breaks the minute you move to API-first traffic. Today, over 80% of the requests hitting our edge are application/json or application/xml, and the malicious content isn’t sitting in ?id=1' OR 1=1 anymore. It’s inside {"user": {"profile": {"bio": " ... "}}} or inside a SOAP envelope with a namespace that hides the real element name.
I've seen teams try to solve this with a single regex over the raw body. It doesn't work, and it'll burn you with false positives. A JSON body like {"message": "a && b || c"} will trigger a command injection rule if you just scan the raw string, even though that value is just chat text. Effective WAF Custom Rule Authoring: Writing Effective Detection Rules Without False Positives depends on understanding structure, not just string matching. You have to parse the body into a tree, then apply rules to specific value nodes while respecting type and context. Without that, you can't distinguish between a key, a string value, and a nested object.
This is where the architecture matters. Your WAF Rule Engine Architecture: How Inspection Engines Process Requests at Line Speed has to decide, per request, whether to buffer, parse, and normalize the body at all. At CDN scale, you can't parse every 2MB JSON blob synchronously without adding 30ms of latency. We run a pre-filter based on Content-Type, Content-Length, and route pattern. Only API and form routes with the right MIME types get sent to the deep content inspection pipeline. Everything else gets a fast-path pass. That separation is what keeps p95 latency under 5ms even when we're parsing millions of bodies per second.
// Phase 2: Request body inspection - selective parsing config
// Applied after header inspection, only for buffered bodies
sec_body_access On
sec_request_body_access On
sec_request_body_limit 134217728
sec_request_body_no_files_limit 1048576
// Only parse what we need to - don't waste cycles on images/multipart
sec_rule REQUEST_HEADERS:Content-Type "^application/(json|xml|soap\+xml)" \
"id:900100,phase:1,pass,nolog,ctl:requestBodyProcessor=JSON"
// Argument parsing with strict type awareness
// /api/v1/* routes get JSON validation + inspection
sec_rule REQUEST_URI "@beginsWith /api/" \
"id:900101,phase:2,pass,nolog,chain"
sec_rule REQUEST_HEADERS:Content-Type "@contains json" \
"ctl:requestBodyProcessor=JSON,ctl:forceRequestBodyVariable=On"
// Block if parsing fails - likely evasion attempt
sec_rule REQBODY_ERROR "!@eq 0" \
"id:900102,phase:2,deny,status:400,msg:'JSON parsing failed - malformed payload',logdata:'%{REQBODY_ERROR_MSG}'"
Parsing Hostile JSON Without Killing Edge Performance
JSON looks simple until you have to parse it securely at 2M RPS. The spec from the HTTP Working Group Specifications is clear enough, but attackers don't follow specs. They send deeply nested objects (10,000 levels deep), duplicate keys with different values, oversized numbers that overflow parsers, and unicode-escaped payloads like \u0027\u0020\u004f\u0052\u0020\u0031\u003d\u0031 which decodes to ' OR 1=1 after parsing. If your parser handles these naively, you've got either a DoS or a bypass.
We learned this the hard way when a customer’s mobile app sent JSON with duplicate keys intentionally - their buggy SDK duplicated "role":"user" and "role":"admin" in the same object. Our first parser took the last value, per RFC 8259's loose guidance, while their backend took the first. An attacker figured out they could smuggle "role":"admin" as the first key and our WAF would only see "user". Now we enforce strict duplicate-key handling: we don't pick one, we flag the request and normalize by rejecting or collapsing with a deterministic rule that matches the origin's behavior. You have to know how your origin parses, or your WAF's view of the payload won't match the application's view - and that mismatch is exactly what attackers exploit.
The other killer is performance. Standard JSON libraries allocate freely and will happily try to parse a 10MB payload with 500k keys, blowing your per-worker memory. At the edge, we use a streaming, zero-copy parser with hard limits: max depth 64, max keys per object 256, max string length 64KB, and total nodes capped at 10k. If any limit is hit, we don't try to be heroic - we either block or flag for deeper async analysis depending on the customer's paranoia level. This approach borrows a lot from modern browser parsing optimizations you’ll see documented in web.dev Articles around efficient JSON handling, but hardened for adversarial input rather than just speed.
{
"waf_json_profile": {
"parser": "streaming_zero_copy",
"limits": {
"max_depth": 64,
"max_nodes": 10000,
"max_keys_per_object": 256,
"max_string_length": 65536,
"max_total_size_bytes": 1048576
},
"normalization": {
"unicode_unescape": true,
"duplicate_key_action": "reject",
"number_coercion": "strict",
"null_handling": "preserve"
},
"inspection_targets": [
"values",
"keys",
"array_elements"
],
"on_limit_exceeded": "block_with_400",
"on_parse_error": "block"
},
"ruleset": [
{
"id": "941330",
"description": "IE XSS Filters - Attack Detected via JSON value",
"target": "ARGS_JSON:$.user.profile.bio",
"operator": "detectXSS",
"transformations": ["utf8Decode", "htmlEntityDecode", "jsDecode"]
}
]
}
XML at the Edge: Where Parsers Go to Die
If JSON is tricky, XML is actively hostile. I’ve spent more on-call nights debugging XML issues than any other payload type. It’s not just the angle brackets. It’s namespaces, CDATA sections, comments that can hide payloads, and the classics: XXE and Billion Laughs. A single XML body can reference an external entity that makes your parser fetch file:///etc/passwd or expand a billion entities and OOM your edge worker. We've seen botnets probe SOAP endpoints with exactly these payloads, just to map which WAFs are still using vulnerable libxml2 defaults.
The first rule we enforce: no external entities, ever. No network fetches, no local file access, no DTD retrieval. Our XML parser runs with XML_PARSE_NOENT disabled and XML_PARSE_DTDLOAD off, with entity expansion capped at 10x the original size. Second, we canonicalize namespaces before inspection. An attacker will send <ns1:script xmlns:ns1="http://www.w3.org/1999/xhtml"> to dodge a rule looking for <script>. If you're not resolving namespaces to their canonical form, you'll miss it. Good WAF Managed Ruleset Comparison: OWASP CRS, Commercial, and Provider-Specific Rules often differ precisely on how well they handle this normalization - CRS has gotten much better, but many commercial rulesets still do naive string matching on raw XML.
We also treat XML and JSON very differently in terms of cost. XML parsing is 4-7x more expensive in CPU than JSON at the same byte size due to namespace resolution and entity handling. You can’t afford to parse every XML body inline on the hot path. We sample and we defer. For high-traffic SOAP customers, we compile an allowlist of expected XPaths from their WSDL and only inspect values at those paths. Anything outside that schema gets extra scrutiny but doesn't get full tree traversal unless flagged. It’s a trade-off between coverage and latency that you have to tune per customer.
| Payload & Parser Config | Avg Parse Time (p50) | Memory Per Request | Max Throughput Per Core | Block Rate on Attack Corpus |
|---|---|---|---|---|
| JSON, streaming parser (1KB body) | 0.11 ms | 8 KB | ~42k RPS | 98.7% |
| JSON, DOM parser (1KB body) | 0.38 ms | 32 KB | ~14k RPS | 99.1% |
| XML, hardened streaming (2KB SOAP) | 0.64 ms | 24 KB | ~9k RPS | 96.3% |
| XML, legacy libxml2 defaults (2KB) | 0.52 ms | 128 KB + network | ~11k RPS | 71.4% (XXE bypass) |
| Raw regex over body (no parsing) | 0.04 ms | 2 KB | ~85k RPS | 61.2% (high false negatives) |
Putting It Into Production: Rule Design That Won't Nuke Legit Traffic
Here's where most teams shoot themselves in the foot — they enable JSON/XML parsing globally and wonder why checkout errors spike 15% overnight. Don't do that. At CDN scale, you've gotta be surgical. We typically start by scoping deep inspection to only the routes that actually accept structured bodies: /api/*, /graphql, /rest/*, maybe a legacy SOAP endpoint at /xmlrpc.php that marketing forgot to deprecate. Everything else? Skip body parsing entirely. We also enforce a hard body inspection limit — usually 128KB for JSON and 256KB for XML. Anything larger gets truncated or bypassed to raw regex, because trying to fully parse an 8MB JSON upload at the edge will cost you 12-18ms of added latency and blow your per-request CPU budget.
The other non-negotiable is content-type aware parsing. Your WAF shouldn't try to JSON-parse a multipart/form-data upload just because someone spoofed the body. We gate parsing on a strict allowlist: application/json, application/xml, text/xml, and application/soap+xml. And we normalize first — lowercase the header value, strip charset parameters like ; charset=utf-8, and decompress if content-encoding: gzip is present. Miss that step and attackers will bypass you with Content-Type: Application/JSON or by double-encoding payloads. Once parsed, you want to write rules against normalized JSON pointers or XPath, not raw body strings. That means $.user.input instead of hunting for "user": in a flat string.
Roll out in monitor-only mode first, every time. We've learned to run new body inspection rules in log for at least 48-72 hours across 100% of edge PoPs before flipping to block. Sample your parse failures too — if you're seeing more than 0.5% parse error rate on a given endpoint, you've got a client sending non-standard JSON (hello, single quotes and trailing commas) and you'll need an exception. Once you're confident, pair your application rules with broader controls. For high-risk endpoints like login or payment APIs, we layer deep inspection with WAF Rate Limiting Integration: Combining Application Rules with Traffic Controls so a single IP can't brute-force payload variations at 200 RPS while you're still tuning signatures.
# Example: Edge WAF rule for hostile JSON/XML with scoped parsing
# Engine: CDN-native (JSON Pointer + XPath) - deploy in LOG then BLOCK
sec_body_parse on
sec_body_parse_content_types application/json text/xml application/xml application/soap+xml
sec_body_limit 131072 # 128KB - skip parsing beyond this, fallback to raw inspection
sec_json_depth_limit 32
sec_json_keys_limit 512
sec_xml_entity_expansion off # Never allow XXE expansion at edge
# Rule 1: Detect SQLi in any JSON value under $.input or nested objects
SecRule JSON:"$.user.email|$.data[*].value" "@detectSQLi" \
"id:100421,phase:2,log,msg:'SQLi in JSON body',tag:'api/json'"
# Rule 2: Block XXE / DTD in XML at /api/legacy-soap
SecRule XML:"//*[contains(text(),'
Beyond the Basics: Evasion Tricks and Why Your Parser Quietly Fails
If you think attackers send pretty-printed JSON, you're in for a rough week. The nastiest evasions we've seen at the edge exploit parser differentials — where your WAF's parser and your origin's parser disagree on what the body means. Classic examples: duplicate keys like {"role":"user","role":"admin"} — Go's encoding/json takes the last value, some WAFs take the first. Or deeply nested JSON hitting your depth limit: [[[[[[...32 levels...]]]]]] with the payload hidden at depth 40. Your WAF stops parsing at 32 and misses it, but Node.js will happily parse 100 levels deep. Same game with XML: attackers stuff comments inside tags <us<!--x-->er>, use excessive namespaces, or send invalid charset declarations to desync parsers.
Troubleshooting this is all about visibility. Don't just look at block rate — watch your edge metrics. We alert on three things: p95 body parse latency (should stay under 1.2ms, if it hits 3ms you're parsing too much), parse error ratio per endpoint, and fallback-to-raw-body rate. If parse latency spikes on a single PoP, you've probably got a client sending 1MB JSON blobs to a route you thought was 2KB. If your WAF logs show JSON_PARSER_ERROR: duplicate keys or XML_PARSER_ERROR: entity expansion limit but the origin returns 200, that mismatch is your blind spot. In that case, temporarily tighten your rule to deny on processor error for that route, and work with backend to enforce strict parsing too. And please, don't forget to normalize Unicode — we've seen WAF bypasses using \u0027 instead of ' to dodge single-quote signatures.
Once your parsing is solid, the real power comes from correlation. A suspicious JSON payload with ${jndi:ldap from an IP with a bot score of 12 is a very different story than the same payload from your mobile app with a score of 92. That's why we almost never deploy body inspection in isolation — we feed WAF Bot Score Integration: Using Bot Detection Signals in Firewall Rules directly into the decision. Let low-reputation bots get blocked on a single signature match, while trusted traffic needs two signals. And when you're inevitably drowning in false positives after enabling XPath rules on a complex XML schema, don't just disable the rule. Use a proper WAF False Positive Tuning: A Systematic Methodology for Reducing Alert Noise workflow: sample 1,000 blocked requests, cluster by JSON path, and build targeted exclusions for things like Base64-encoded SAML assertions that naturally look like XSS.
Frequently Asked Questions
Should we parse every request body or only API routes?
Only API routes. Parsing every POST at the edge is a performance killer. Scope inspection to specific paths and content-types. We've seen global body parsing add 4-6ms p50 latency and 30% more CPU per PoP — totally unnecessary when 80% of your traffic is form posts or static assets that don't need it.
What's a safe body size limit for edge JSON/XML parsing?
128KB for JSON and 256KB for XML covers 99.2% of legitimate API traffic in our dataset. Beyond that, truncate and fall back to lightweight raw inspection. Parsing a 1MB+ body synchronously at the edge can push tail latency to 15-25ms and risks OOM kills under burst. If you have legitimate large uploads, handle them at the origin with async validation, not inline at the CDN.
Why is my WAF missing payloads hidden in deeply nested JSON?
You've likely hit your parser's depth or key-count limit. Most edge parsers default to 16-32 levels and 500-1000 keys to prevent DoS. Attackers intentionally nest payloads at depth 50+ to hide them. Check your REQBODY_PROCESSOR_ERROR logs. If you're seeing depth exceeded errors on attack traffic, either raise the limit slightly (we run 32) or add a rule that blocks requests that exceed parse limits on sensitive endpoints.
How do we handle XML external entity (XXE) attacks at the CDN?
Never allow DTD or entity expansion at the edge. Set entity expansion to off and block any request containing <!DOCTYPE or <!ENTITY before parsing. Your parser should reject it in phase 1 and you should deny with 403. Don't try to sanitize XXE — just block. Also ensure your origin's XML parser is hardened the same way, or attackers will bypass the edge via parser differentials.