XSS Variants
Reflected, stored, DOM — same bug, three delivery modes.
XSS Variants
Cross-site scripting is one bug with three delivery modes. The bug is always the same: attacker-controlled data reaches a rendering sink without the right escaping for that sink's context. What changes is how the payload gets there.
Analogy
Imagine a restaurant where the maître d' reads dinner orders aloud in the style of the customer who wrote them — complete with any stage directions the customer happens to scribble in the margins. A prankster writes "Medium steak. Bring the chef out and have them sing happy birthday." The maître d', trained only to read whatever ink appears on the ticket, dutifully summons the chef. A reflected attack is a one-off prank note handed in by the victim themselves; a stored attack is graffiti scrawled inside the menu so every customer's order triggers it; a DOM-based attack skips the kitchen entirely and whispers the instruction straight into the waiter's ear. The fix is always the same: the maître d' must treat ink as text to be described, never as stage directions to be enacted.
Reflected
The server takes input from the request and echoes it straight back into the response. The attacker sends the victim a link with the payload in the query string; the victim clicks it; the server's response contains the payload rendered into the page.
- Source: query parameter, form value, path segment.
- Sink: anywhere the server's template writes the value — HTML body, attribute, inline JS.
- Fix: context-aware escaping at render time. Your template engine almost certainly does this when you use
{value}style interpolation — it'sdangerouslySetInnerHTML(React) or|safe(Jinja) or string concatenation that disables it.
Stored
The payload is persisted — database, filesystem, key-value store — then served to other users later. A malicious comment that shows up on every visitor's feed is the canonical example. It is more dangerous than reflected: no social engineering needed, the payload hits every viewer.
- Source: a POST body, an upload, or any write path that saves user input.
- Sink: the renderer that later displays that stored value.
- Fix: same as reflected — escape at output time. Do not try to "clean" input on the way in; you will miss cases and cripple legitimate content.
DOM-based
The payload never touches the server. The browser itself reads something attacker-controlled (location.hash, location.search, document.referrer, a postMessage payload) and writes it to a DOM sink (innerHTML, document.write, eval, a src attribute) — all in client-side JS.
Because the payload never hits the server, server-side logs won't see it, WAFs won't see it, and server-side escaping can't help.
- Fix: never write untrusted strings into HTML sinks. Use
textContent, notinnerHTML. If you must render HTML, use a sanitizer (DOMPurify) and adopt Trusted Types in strict mode.
The escaping rule you actually need
Escaping is context-sensitive. The same value needs different treatment depending on where it lands:
| Context | Example | Required encoding |
|---|---|---|
| HTML body | <div>{name}</div> |
HTML-entity encode & < > " ' |
| Attribute | <a href="{url}"> |
HTML-attribute encode, and always quote |
| JS string | var n = "{name}"; |
JS string encode (or better, inject via data-* and read from the DOM) |
| URL | <a href="{url}"> |
Validate scheme against https:/http: and URL-encode path/query |
| CSS | color: {value} |
Don't. Use a data attribute + safe CSS variable |
CSP — defense in depth, not a replacement
A strict Content Security Policy limits what an injected payload can do. With a solid CSP, even a missed escape usually can't execute — the browser refuses inline script, refuses eval, refuses external scripts from unapproved hosts.
Content-Security-Policy:
default-src 'none';
script-src 'self' 'nonce-r4nd0m';
style-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
Important: CSP is a second line of defense, not the first. Fix the escaping. Add the CSP. Don't ship one without the other.
The checklist
- Never concatenate untrusted data into HTML, attributes, JS, or URLs. Let the template engine do the escaping.
- Never use
innerHTML/document.write/evalwith attacker-controlled data. - Ship a strict CSP with nonces or hashes — never
'unsafe-inline'onscript-src. - Set
X-Content-Type-Options: nosniffand serve user uploads from a separate origin. - Treat
target="_blank"links as untrusted — addrel="noopener noreferrer".
Tools in the wild
6 tools- libraryDOMPurifyfree tier
Cure53's DOM-only HTML sanitizer — safe defaults for user-supplied HTML in the browser.
- specTrusted Typesfree tier
Browser API that locks down dangerous sinks (`innerHTML`, `eval`) at the platform level.
- specContent Security Policyfree tier
Header-driven defense in depth — strict CSP blocks injected scripts even when XSS lands.
- cliOWASP ZAPfree tier
Open-source web app scanner; active scanning catches reflected + DOM XSS in CI.
- cliBurp Suitefree tier
PortSwigger's intercepting proxy — the standard tool for manual XSS hunting.
- libraryhtml-validatefree tier
Build-time linter that flags `dangerouslySetInnerHTML` and other XSS-adjacent patterns.