Cross-site scripting, or XSS, happens when a web application lets data an attacker controls be interpreted as code in another user's browser. The browser cannot tell the difference: the script runs with the page's origin, so it can read anything the page can read, call any API the page can call with the user's cookies, and change what the user sees. One stored XSS on a support dashboard can take over every administrator who opens a ticket. That is why it has been on every web vulnerability list for two decades, and why frameworks that escape by default have reduced it without eliminating it.
This article treats XSS as what it is, a data-flow bug from an untrusted source to an executable sink. It covers the three classes, why the right encoding depends on where data lands, a worked vulnerable application and its fix, sanitising rich HTML, the escape hatches in common frameworks, Trusted Types and Content Security Policy as backstops, and how to test. All examples are for defending and testing your own applications.
Why it happens: data crossing into code
HTML documents mix markup, attribute values, URLs, CSS and JavaScript in one stream of text. When a server or script builds that stream by concatenating strings, any character that has meaning in the surrounding syntax, such as a quote, an angle bracket or a colon in a URL scheme, can end the data and start code. The attacker's goal is simply to get their text parsed as something executable. The classic proof of concept is <img src=x onerror=alert(1)>: the image fails to load and the event handler runs.
Once it runs, the same-origin policy works against you. The script can read the DOM including CSRF tokens, issue authenticated requests, read tokens kept in localStorage, install a fake login form, or quietly change account settings. HttpOnly cookies stop the script from reading the session cookie directly, but it does not need to: it can act as the user from inside the page. That is also why XSS defeats CSRF defences, as explained in CSRF defence.
The three classes
| Class | Where the payload lives | Typical example | Who sees it |
|---|---|---|---|
| Stored | Database, files, logs, then rendered | Comment, profile name, ticket body, uploaded SVG | Everyone who views the page |
| Reflected | The request itself, echoed in the response | Search term or error message echoed from a query parameter | Whoever follows a crafted link |
| DOM-based | Client-side code moves a source to a sink | location.hash written with innerHTML | Whoever follows a crafted link; server may never see the payload |
The classes differ in delivery, not in fix. Stored XSS is the most damaging because it needs no interaction beyond viewing a page, and it often hits privileged internal users through admin tools that render customer data. DOM-based XSS is the one server-side scanners and WAFs miss, because a URL fragment after # is never sent to the server. Mutation XSS, or mXSS, is a subtype in which markup that looks safe after sanitisation is changed by the browser's parser into something that executes; it is the main reason to use a maintained sanitiser instead of writing your own.
Context decides the encoding
There is no single escape function that makes a string safe everywhere. The right transformation depends on the syntax the data lands in, and using the wrong one is a frequent root cause:
| Output context | Example template | Correct handling |
|---|---|---|
| HTML element content | <p>{{name}}</p> | HTML-entity encode & < > " ' |
| Quoted attribute value | <input value="{{name}}"> | HTML-attribute encode; always quote attributes |
| URL in href or src | <a href="{{url}}"> | Parse, allow only http and https schemes, then attribute-encode |
| Inside a script block | <script>var u = {{json}}</script> | Serialise as JSON and escape < as \u003c |
| Event handler or eval | onclick="go({{x}})" | Do not put data here; attach handlers in code |
| CSS | style="color: {{c}}" | Allow-list values; do not interpolate free text |
Two rows deserve emphasis. HTML encoding does not neutralise a javascript: URL, because the payload contains no special characters; only scheme validation stops it. And HTML encoding inside a script block is the wrong layer: the HTML parser ends the script at the first </script> regardless of JavaScript quoting, so embedded JSON must have its angle brackets escaped.
Worked example: a comment feature, broken and fixed
A small Express application stores comments and renders them by string concatenation. It also has a client-side preview that copies the URL fragment into the page. Both are vulnerable:
// server.js -- VULNERABLE
app.post("/comments", (req, res) => {
db.comments.insert({ author: req.body.author, body: req.body.body });
res.redirect("/comments");
});
app.get("/comments", (req, res) => {
const items = db.comments.all()
.map(c => `<li><b>${c.author}</b>: ${c.body}</li>`).join("");
res.send(`<ul>${items}</ul><a href="${req.query.back}">back</a>`);
});
// preview.js -- VULNERABLE (DOM-based)
document.getElementById("preview").innerHTML =
decodeURIComponent(location.hash.slice(1));Trace the flows. A comment body containing an image tag with an onerror handler is stored and then executed for every reader: stored XSS. A link whose back parameter is javascript:fetch('/api/me').then(...) runs when the victim clicks back: reflected XSS through a URL sink, which HTML escaping alone would not fix. A link ending in #<img src=x onerror=...> fires in the preview without the server ever seeing it: DOM-based XSS. The fixed version uses a template engine that escapes by default, validates the URL scheme, and writes text with textContent:
// server.js -- FIXED (EJS escapes <%= %> output by default)
app.set("view engine", "ejs");
function safeBack(u) {
try {
const url = new URL(u, "https://app.example.com");
return url.origin === "https://app.example.com" ? url.pathname + url.search : "/";
} catch { return "/"; }
}
app.get("/comments", (req, res) => {
res.render("comments", { items: db.comments.all(), back: safeBack(req.query.back) });
});
// comments.ejs: <li><b><%= c.author %></b>: <%= c.body %></li>
// <a href="<%= back %>">back</a>
// preview.js -- FIXED
document.getElementById("preview").textContent =
decodeURIComponent(location.hash.slice(1));Note what changed and what did not. Input was not filtered on the way in; the stored data is still the raw text the user typed. Encoding happens at output, for the specific context, which keeps data reusable in JSON APIs, exports and other renderers that need different encoding. Input validation is still worth doing for type and length, but it is not the XSS defence.
One more sink hides in many server-rendered apps: initial state embedded in a script block for the client to hydrate. JSON.stringify does not escape angle brackets, so a comment containing </script><script>... closes the block and opens a new one. Replace < with \u003c in the serialised string, or put the JSON in a non-executable <script type="application/json"> element and read it with JSON.parse.
When users need rich HTML: sanitise, do not escape
Comment systems, wikis and email clients sometimes must render user-supplied markup. Escaping would destroy it, so the job becomes removing everything except an allow-list of harmless elements and attributes. Use a maintained, parser-based sanitiser such as DOMPurify in the browser or the equivalent for your server language; regular expressions cannot parse HTML and lose to mXSS.
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(untrustedHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li", "code", "pre"],
ALLOWED_ATTR: ["href"],
});
container.innerHTML = clean; // sanitise immediately before the sink, never earlier
// Newer browsers: built-in sanitiser on the element itself (feature-detect first)
if ("setHTML" in Element.prototype) {
container.setHTML(untrustedHtml);
}Three rules keep sanitisation honest. Sanitise as the last step before the sink, because any later string manipulation can reintroduce markup. Do not sanitise and then parse again in a different context, such as feeding sanitised HTML into a Markdown renderer or a template. And treat uploaded SVG and HTML files as active content: serve them from a separate domain or with Content-Disposition: attachment. The browser-native Element.setHTML() from the Sanitizer API shipped in Firefox 148 in February 2026 and in Chromium 146; until it is available to all your users, keep a library fallback.
Framework escape hatches
Modern frameworks escape interpolated values by default, which is why XSS in them almost always comes from an explicit opt-out. Grep for these in code review and treat each hit as needing a justification:
| Framework | Safe by default | Escape hatch to audit |
|---|---|---|
| React | JSX text and attribute values | dangerouslySetInnerHTML, user-controlled href values |
| Angular | Interpolation and built-in sanitiser | bypassSecurityTrustHtml and related methods |
| Vue | Mustache interpolation | v-html |
| Jinja2 / Django | Autoescape in HTML templates | |safe, Markup(), mark_safe |
| Server-side JS | EJS <%= %> | EJS <%- %>, template literals building HTML |
URLs remain a gap in most of them: escaping a value placed in href does not validate its scheme. Validate URLs from users against an allow-list of schemes in every framework.
Defence in depth: Trusted Types, CSP and cookies
Sink-level fixes fail when one call site is missed, so add controls that limit the blast radius. Trusted Types, enabled with the CSP directive require-trusted-types-for 'script', makes the browser reject plain strings at DOM XSS sinks such as innerHTML; only objects created by a named policy are accepted, which turns every sink into a reviewable policy call. It was long Chromium-only; Firefox 148 shipped it in February 2026, so check current compatibility data for the browsers you support.
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html
const policy = trustedTypes.createPolicy("app-html", {
createHTML: (s) => DOMPurify.sanitize(s),
});
el.innerHTML = policy.createHTML(userHtml); // a raw string here would throwA strict, nonce-based Content Security Policy stops injected inline scripts and handlers from running even when markup gets through; its design, including strict-dynamic and report-only rollout, is covered in the CSP architecture article. Mark session cookies HttpOnly, Secure and SameSite, and keep long-lived tokens out of localStorage, where any script can read them; session management goes deeper. Do not rely on the old X-XSS-Protection header: browsers removed the filter it controlled, and current guidance is to disable it or omit it.
Testing and failure modes
Combine three kinds of testing. Static analysis traces sources to sinks and flags opt-outs such as dangerouslySetInnerHTML; dynamic scanning fuzzes running pages; and unit tests pin known payloads against each renderer so a template change cannot silently regress. A minimal regression test asserts that dangerous input comes back inert:
const PAYLOADS = ['<img src=x onerror=alert(1)>', '"><svg onload=alert(1)>', "javascript:alert(1)"];
test.each(PAYLOADS)("comment renders inert: %s", async (p) => {
await post("/comments", { author: "t", body: p });
const html = await get("/comments");
expect(html).not.toMatch(/<img[^>]*onerror|<svg[^>]*onload/i);
});Tool coverage and trade-offs are compared in SAST and DAST. The failures that recur in incident reviews are consistent:
| Failure | Why it slips through |
|---|---|
| Admin dashboard renders customer data | Internal tools skip the framework or review |
| Encoding for the wrong context | HTML encoding applied to URLs or script blocks |
| Sanitise, then transform | Markdown or templating after sanitising reintroduces markup |
| Double decoding | Input decoded again after validation |
| Uploaded SVG or HTML served inline | Files on the main origin execute as pages |
| DOM XSS missed by scanners | Fragment payloads never reach server logs or WAFs |
What to do next
- Inventory sinks: grep for innerHTML, outerHTML, insertAdjacentHTML, document.write, eval and every framework escape hatch, and assign an owner to each.
- Make sure every server-rendered template autoescapes, and remove string-concatenated HTML.
- Validate user-supplied URLs against an allow-list of schemes before they reach href or src.
- Route all rich HTML through one maintained sanitiser, called immediately before the sink.
- Serve user uploads from a separate origin or as attachments.
- Deploy a nonce-based CSP in report-only mode, fix the reports, then enforce it; add Trusted Types once your sinks are under policies.
- Add payload regression tests for each renderer and run SAST rules for source-to-sink flows in CI.