Web Application Security · intermediate · ~12 min
**What you will learn** - Tell the three XSS families apart (reflected, stored, DOM-based) by *where the data enters* and *where it lands*. - Explain the single root cause of all XSS: untrusted data placed into a code context without correct, context-aware encoding. - Choose the *correct* fix for each output context — HTML body, attribute, JavaScript, URL — instead of reaching for a blacklist. - Add Content Security Policy (CSP) and a vetted sanitizer (DOMPurify) as defense in depth, and know their limits. - Detect XSS attempts through logging and verify a fix actually rejects a payload while still accepting legitimate input. - Work safely: reproduce XSS only in an authorized, isolated lab, and write it up defensively.
Security objective. The asset you are protecting is the victim user's browser session and everything reachable from the site's origin — their cookies, their logged-in identity, the DOM, and any action they can perform in the app. The threat is an attacker who injects their own JavaScript so it runs inside that origin. In this lesson you will learn to detect where untrusted data reaches a code context, prevent it with context-aware output encoding, and verify the fix.
Cross-site scripting (XSS) is a vulnerability where an attacker gets their JavaScript to execute in another user's browser, inside the target site's origin (its scheme + host + port). Once the script runs as the site, the browser trusts it as much as the site's own code. That is the whole danger: same-origin policy — the rule that keeps evil.example from reading bank.example's data — does not help you here, because the malicious script is running as bank.example.
This builds directly on your prerequisite, "Testing the web: intercepting and shaping requests." There you learned to intercept an HTTP request, change a parameter, and observe how the response changes. XSS is exactly that skill applied to one question: does a value I control come back inside the page in a way the browser will treat as code? You will use the same intercept-and-shape workflow to probe an input and read the reflected response.
Where it shows up: search boxes that echo your term, comment fields, profile names, error messages that repeat your input, URL fragments read by client-side JavaScript, and any innerHTML assignment fed by user data.
The good news is that the defense is well understood and mechanical. XSS is not fixed by trying to guess and ban "bad" input. It is fixed by encoding output for the exact context it lands in, so the browser reads attacker text as text and never as code.
In authorized professional work — a bug bounty engagement, a scoped penetration test, or a secure-code review — XSS is one of the highest-value findings you will report, for three reasons.
Impact is real and immediate. A working XSS lets an attacker run code as the victim: steal a session cookie that is not HttpOnly, silently make requests as the logged-in user (change email, add an SSH key, transfer funds), read sensitive page content, keylog a login form, or pivot to further attacks. On an admin page, a stored XSS can mean full application takeover.
It is everywhere. XSS has sat near the top of the OWASP Top 10 for two decades. Every app that shows user-influenced data — which is every app — is a candidate. Modern frameworks removed the easy cases, which means the ones that survive tend to hide in dangerouslySetInnerHTML, innerHTML, href/src attributes, inline event handlers, and JSON embedded in <script> blocks.
The fix is widely misunderstood, so report quality matters. Many developers "fix" XSS by stripping <script> on input, which is both incomplete and in the wrong place. A strong finding explains the correct remediation — context-aware output encoding plus CSP plus a vetted sanitizer — and proves it. Being the person who can explain and verify the real fix, not just the alert box, is what makes a security professional valuable.
Each concept below is taught on its own: what it is, how it works, when it applies, and a pitfall.
Definition. An origin is the triple (scheme, host, port) — e.g. https://app.example:443. The browser's same-origin policy isolates origins from each other. Script that runs within an origin is fully trusted by that origin.
How it works. When your payload executes as part of app.example's page, it inherits every capability the site's own scripts have: read and write the DOM, read non-HttpOnly cookies, use existing session credentials to call the site's APIs, and read anything rendered on the page.
When it matters / when not. It matters any time attacker-controlled text can become executable code in the page. It does not let the attacker read a different origin's data — but it rarely needs to, because the victim's valuable session is on this origin.
Pitfall. Assuming HttpOnly cookies make XSS harmless. They stop cookie theft, but the script can still act as the user directly (send authenticated requests from inside the page), which is often worse.
Definition. The payload travels in the request (query string, form field, header) and is echoed back into the same response without correct encoding.
How it works. A search page that renders You searched for: <the term> directly into HTML will execute <the term> if it contains markup. Delivery is usually a crafted link the victim clicks.
When / when not. Reflected XSS needs the victim to visit an attacker-shaped URL; it is not persisted. Lower reach than stored, but still serious.
Pitfall. Thinking reflected XSS is "self-only" and harmless. A phishing link or an ad can deliver it to many victims.
Definition. The payload is saved by the app (comment, profile bio, product review, support ticket) and later served to everyone who views that content.
How it works. One submission, many victims — no per-victim link needed. If the stored content is shown to admins, the blast radius includes privileged accounts.
When / when not. Any field whose value is displayed to other users later. This is generally the most dangerous type, which is exactly the point the quiz makes — so remember why: persistence plus broad, unwitting audience.
Pitfall. Sanitizing on display in one template but forgetting a second template (e.g. an email digest or an admin dashboard) that renders the same stored value.
Definition. The vulnerability lives entirely in client-side JavaScript: a source of attacker-controlled data (e.g. location.hash, location.search, document.referrer) flows into a dangerous sink (e.g. element.innerHTML, document.write, eval) without sanitization. The server may never see the payload.
How it works. document.getElementById('out').innerHTML = location.hash.slice(1) will parse whatever is after # as HTML.
When / when not. Common in single-page apps. Server-side output encoding does not fix it — the flaw is in the browser code.
Pitfall. Testing only server responses. A DOM XSS payload after # is never sent to the server, so a proxy that only inspects request/response bodies can miss it — you must inspect the client code and the live DOM.
Definition. Untrusted data is placed into a code context (HTML, attribute, JavaScript, URL, CSS) without the encoding that context requires. The browser then parses the data as code.
How it works. The same string is safe in one context and dangerous in another. "><svg onload=...> breaks out of an attribute; ';alert(1)// breaks out of a JavaScript string. There is no single "safe" transform — safety is per-context.
Pitfall. Believing input filtering solves it. Input arrives in many encodings and is later used in many contexts; the reliable place to make data safe is at output, matched to that context.
THREAT MODEL — user comment feature
[ Attacker ] [ Other victim users ]
| ^
| (1) submits comment text | (5) views page,
| containing markup | browser renders
v | stored comment
+--------------------- TRUST BOUNDARY ---------------------+
| Web application (origin: https://app.example) |
| |
| Entry point A: POST /comment body=text <-- untrusted |
| | |
| v (2) stored verbatim |
| [ Database ] <-- payload now persisted |
| | |
| v (3) GET /thread renders comment |
| Template output --(4) encode HERE, per context--> |
| | |
+--------|------------------------------------------------+
v
HTML response to victim's browser (origin app.example)
Assets protected : victim session cookie, victim identity/actions, DOM
Trust boundary : the app process; everything left of it is untrusted
Entry points : POST /comment (stored), GET params (reflected),
location.hash / DOM sources (DOM-based)
Correct control : output encoding at step (4), matched to context,
backed by CSP on the response and DOMPurify if rich
Knowledge check.
POST /comment on? (The app process boundary; the request body arrives from the untrusted side.)# never reaches the server.)The core defensive primitive is HTML-entity encoding — turning characters that have meaning in HTML into their harmless entity form so the browser prints them literally.
| Character | Encoded as | Why it is dangerous raw |
|---|---|---|
& |
& |
starts an entity; encode first |
< |
< |
opens a tag |
> |
> |
closes a tag |
" |
" |
ends a double-quoted attribute |
' |
' |
ends a single-quoted attribute |
A minimal, annotated server-side pattern (Node/Express, lab-safe):
// escapeHtml: make a string safe to place in an HTML *body* or a
// double-quoted attribute. Order matters: & must be replaced first.
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&") // do & first, or you double-encode later ones
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Render user text into the page body — now inert:
res.send(`<p>You searched for: ${escapeHtml(req.query.q)}</p>`);
Key structural rules:
<script> JS string → JavaScript-string encode (or better, pass data as JSON via a data- attribute and read it with textContent). Inside href/src → URL-encode and verify the scheme (block javascript:).textContent/innerText (never innerHTML) when inserting plain text on the client.Content-Security-Policy: default-src 'self'; script-src 'self' — it blocks inline <script> and onload= handlers, so a missed encoding is far less likely to execute.XSS means injecting attacker JavaScript that runs in another user's browser, inside the site's origin.
Because the script runs as the site, it can:
HttpOnly,innerHTML or similar) without sanitizing it. This kind never touches the server.Untrusted data is placed into an HTML, JavaScript, attribute, or URL context without correct encoding. As a result, the browser parses it as code instead of as text.
For example, if a username is shown directly in the page and someone sets their name to <script>...</script>, the browser runs it as a script instead of printing it as text.
dangerouslySetInnerHTML or innerHTML.The example below shows the insecure → secure → verify shape for a reflected search endpoint, using Node's built-in http module only (no external hosts, runs on localhost).
// vuln-search.js — LAB ONLY. Start with: node vuln-search.js
// Then open http://localhost:3000/?q=hello
const http = require("http");
const { URL } = require("url");
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost:3000");
const q = url.searchParams.get("q") || "";
// BUG: user input concatenated straight into HTML -> reflected XSS.
const html = `<!doctype html><h1>Search</h1>
<p>You searched for: ${q}</p>`;
res.writeHead(200, { "Content-Type": "text/html" });
res.end(html);
}).listen(3000, "127.0.0.1",
() => console.log("VULN lab on http://localhost:3000"));
In the lab, requesting /?q=<b>hi</b> renders bold text — proof the value is parsed as HTML, not shown literally. That is the vulnerability signal. (A markup payload such as an onerror image handler would execute; you only need the bold-text proof to confirm the flaw — you do not need a working script payload to demonstrate it.)
// safe-search.js — the fixed version.
const http = require("http");
const { URL } = require("url");
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """)
.replace(/'/g, "'");
}
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost:3000");
const q = url.searchParams.get("q") || "";
// FIX: encode for the HTML-body context at the moment of output.
const html = `<!doctype html><h1>Search</h1>
<p>You searched for: ${escapeHtml(q)}</p>`;
res.writeHead(200, {
"Content-Type": "text/html",
// Defense in depth: no inline scripts allowed to run.
"Content-Security-Policy": "default-src 'self'; script-src 'self'"
});
res.end(html);
}).listen(3000, "127.0.0.1",
() => console.log("SAFE lab on http://localhost:3000"));
// verify.js — run against the SAFE server: node verify.js
// Asserts: markup is neutralized, and normal text still displays.
const http = require("http");
function get(q) {
return new Promise((resolve, reject) => {
const path = "/?q=" + encodeURIComponent(q);
http.get({ host: "127.0.0.1", port: 3000, path }, (r) => {
let body = "";
r.on("data", (c) => (body += c));
r.on("end", () => resolve({ status: r.statusCode, body }));
}).on("error", reject);
});
}
(async () => {
// BAD input must NOT appear as live markup.
const bad = await get("<b>hi</b>");
const neutralized = bad.body.includes("<b>hi</b>");
const escapedRaw = !bad.body.includes("<b>hi</b>");
// GOOD input must still render correctly.
const good = await get("algebra");
const accepted = good.body.includes("You searched for: algebra");
console.log("reject markup (encoded) :", neutralized && escapedRaw);
console.log("accept normal text :", accepted);
if (neutralized && escapedRaw && accepted) console.log("PASS");
else { console.log("FAIL"); process.exit(1); }
})();
What to expect. Against safe-search.js, verify.js prints reject markup (encoded) : true, accept normal text : true, then PASS. Against the vulnerable server it prints FAIL, because the raw <b>hi</b> appears unencoded in the body. This is the heart of mitigation verification: the same test must show the payload neutralized and legitimate input still working.
Cleanup / reset. Stop each server with Ctrl+C; there is no database or file written, so nothing else to reset. Keep the lab bound to 127.0.0.1 so it is unreachable from your network.
Walkthrough of the reflected example and its fix.
Vulnerable server (vuln-search.js):
new URL(req.url, "http://localhost:3000") parses the incoming path so you can read query parameters reliably.url.searchParams.get("q") extracts the attacker-controlled value. Nothing about it is trusted.q directly between <p> tags. This is the defect: the string enters an HTML-body context with no encoding.res.end(html) sends it. The browser now parses q as markup. If q is <b>hi</b>, the browser builds a bold element; if it were an executable payload, the browser would run it — same mechanism, worse outcome.Secure server (safe-search.js):
escapeHtml replaces & first — critical, because if you replaced < first, the & you introduce in < would then be re-encoded into &lt;, corrupting output. Order is part of correctness.<b>hi</b> becomes the literal text <b>hi</b>, which the browser displays as <b>hi</b> but does not parse as a tag.script-src 'self' means even if some other path leaked an inline <script>, the browser would refuse to execute it — a second, independent layer.Verification (verify.js) trace:
| Step | Input q |
What the body contains | Assertion | Meaning |
|---|---|---|---|---|
| 1 | <b>hi</b> |
<b>hi</b> |
neutralized == true |
markup was encoded |
| 2 | <b>hi</b> |
no raw <b>hi</b> |
escapedRaw == true |
nothing live slipped through |
| 3 | algebra |
You searched for: algebra |
accepted == true |
legitimate input unharmed |
All three true → PASS. The test deliberately checks both directions: a fix that also broke normal search (e.g. deleting all <) would fail step 3, which is how you catch over-aggressive "fixes."
Real mistakes, each as wrong approach → why wrong → corrected → how to recognize/prevent.
1. Blacklisting <script> on input.
<script> when data arrives.<script> (<img onerror=...>, <svg onload=...>, javascript: URLs, event handlers), and encodings/case variations bypass naive filters. Filtering also happens in the wrong place — the same input may be used in several contexts.<script>, treat it as incomplete.2. Using one encoding for every context.
<script> block or an href.';alert(1)//) or a javascript: URL. Each context has its own escaping rules.data- attribute and read with textContent; URL → URL-encode and validate the scheme.3. Trusting the framework blindly.
dangerouslySetInnerHTML with user data.dangerouslySetInnerHTML, innerHTML, document.write, eval.4. Treating a sanitizer and an encoder as interchangeable.
5. Fixing display in one place, missing another.
Is a value being parsed as code? Send a benign probe like <b>xyz</b> (or '"><i>xyz for attribute contexts). If it renders styled/broken instead of appearing literally, the value is entering a code context unencoded. You never need a live script payload to confirm the flaw in a lab.
Which context am I in? View source (not just the rendered page) and locate your probe. Is it between tags (HTML body)? Inside attr="..." (attribute)? Inside <script>...</script> (JS)? Inside href="..." (URL)? The context dictates the correct encoder — a mismatch is the usual cause of a "fixed but still vulnerable" result.
DOM-based cases: open DevTools, set breakpoints or search the JS bundle for sinks (innerHTML, document.write, eval, setAttribute on href/src) and sources (location, document.referrer, postMessage). Because these payloads (e.g. after #) may never hit the server, inspect the live DOM and the client code, not just network responses.
My encoding "didn't work." Check ordering (& must be encoded first), check you encoded at output (not input), and check you used the right encoder for the context. Confirm the response Content-Type is text/html — the wrong type can change parsing.
Questions to ask when it fails:
innerHTML-style sink?Security & safety — detection and logging for XSS.
XSS often executes in a browser you cannot see, so server-side telemetry is your main detection surface. Log enough to detect and investigate, and never log secrets.
What to log (per suspicious request/response event):
report-to/report-uri endpoint. A spike in script-src violations is a strong signal that a payload reached a page and CSP blocked it — high-value detection.What to NEVER log:
Events that signal abuse:
<, >, on...=, javascript:, or <script/<svg/<img where the field expects plain text.script-src/inline violation reports.Referer/Origin combined with reflected parameters.How false positives arise (and how to reason about them):
<, >, or code samples (a programming forum, a bug tracker). Encoding handles these safely without blocking them — so encode, don't reject, and treat the WAF/pattern hits as signals to review, not as proof of attack.A concrete authorized use case. You are contracted for a scoped web application penetration test of app.example (in-scope, written permission in hand, testing window agreed, non-production or explicitly-approved environment). On the profile "display name" field you enter the benign probe <i>test</i> and see it rendered in italics on your own profile page — and, critically, on the public members list viewed as a second test account. That is a stored XSS. You capture the request, the rendered response (source view showing the unencoded tag), and note the affected component and preconditions. You do not deploy a real credential-stealing payload; the italic-text proof plus impact analysis is sufficient and safer. You then write the finding with a severity justified by exploitability and impact (stored, reaches other users including potential admins → high), and a remediation: context-aware output encoding on every render path, a CSP, and DOMPurify if rich text is required, with a retest step.
Professional best-practice habits.
| Habit | Beginner | Advanced |
|---|---|---|
| Validation | Validate input type/shape (length, allowed chars for the field) as a usability guard | Treat validation as complementary, never the XSS control; keep it separate from output encoding |
| Output encoding | Always encode text you place in HTML | Pick the encoder per context (HTML/attr/JS/URL/CSS); audit every sink |
| Least privilege | Mark session cookies HttpOnly, Secure, SameSite |
Scope tokens narrowly; assume script may run and limit what a session can do |
| Secure defaults | Prefer textContent over innerHTML |
Adopt a strict CSP (nonce/hash-based) and Trusted Types where supported |
| Logging | Record rejected/encoded events with IDs | Wire CSP violation reporting and alert on spikes |
| Error handling | Never reflect raw input in error pages | Fail closed; render errors through the same encoding pipeline |
Authorization checklist (before any lab or test):
localhost, a container, an intentionally-vulnerable VM, or a CTF — never a third party.All tasks are lab-only (localhost/container/intentionally-vulnerable VM you own or are authorized to use). Each ends with remediation and verification.
Beginner 1 — Spot the context.
href), state the correct encoder for each.href cares about the URL scheme.Beginner 2 — Neutralize a reflected echo.
vuln-search.js locally, confirm <b>hi</b> renders bold, then apply escapeHtml and confirm it now shows literally.q=<b>hi</b>; output must contain <b>hi</b> after the fix.127.0.0.1; stop the server when done.& first.Intermediate 1 — Add and prove a CSP.
Content-Security-Policy: default-src 'self'; script-src 'self' to the safe server and demonstrate it blocks an inline script.onerror/<script> would not execute, and why CSP is defense in depth, not the primary fix.Intermediate 2 — Find every render path for a stored field.
<i>x</i>; no data exfiltration.Challenge — Sanitizer vs. encoder decision + verification harness.
<b>, <a href>; block scripts, event handlers, javascript: URLs), and write a verification script that asserts a disallowed payload is stripped while <b>ok</b> and a normal https: link survive.javascript: in an href is removed.verify.js from the lesson; test the href scheme explicitly.Main concepts. XSS is attacker JavaScript executing in a victim's browser inside the site's origin, giving it the site's own powers (DOM, non-HttpOnly cookies, authenticated requests). It comes in three delivery forms — reflected (echoed from a request), stored (persisted and served to everyone — the most dangerous, because of persistence plus a broad unwitting audience), and DOM-based (a client-side source flows into a dangerous sink, often never touching the server). The single root cause is untrusted data entering a code context without correct, context-aware encoding.
Key syntax/commands. HTML-entity-encode at output — &→& (first!), <→<, >→>, "→", '→'. Choose the encoder by context (HTML body/attribute vs. JavaScript vs. URL). Prefer textContent over innerHTML. Add a CSP header (default-src 'self'; script-src 'self') as defense in depth, and DOMPurify for must-allow rich HTML.
Common mistakes. Blacklisting <script> on input; using one encoding for every context; trusting the framework while calling dangerouslySetInnerHTML/innerHTML; fixing one render path but missing another; confusing a sanitizer with an encoder.
What to remember. Encode output, matched to context — that is the primary fix. CSP and sanitizers are layers, not substitutes, and nothing is ever "completely secure." Always verify both directions: the payload must be neutralized and legitimate input must still work. And only ever reproduce XSS on systems you own or are explicitly authorized to test, in an isolated lab, with benign proofs and cleanup.
Misconceptions to retire: passing a scanner does not prove a page is safe; input filtering is not the fix; HttpOnly cookies limit theft but not action-as-user.