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.

Advertisement

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.

XSS is a data-flow bug: untrusted source reaches an executable sinkAttacker inputform, URL, header, uploadURL fragment / postMessagenever reaches the serverServerstores or reflects inputClient JavaScriptreads and writes DOMDOM-basedHTML responseSinksHTML: innerHTML, templatesattributes: on*, hrefJavaScript: eval, setTimeoutURL: javascript: schemeDefenses at the sinkcontext-aware encoding, sanitiser, safe DOM APIs, Trusted TypesDamage limitsnonce CSP, HttpOnly cookies, no tokens in localStorageSame origin =same power as the user
XSS as data flow. Input reaches an executable sink either through the server, for stored and reflected XSS, or entirely in the browser, for DOM-based XSS. The primary defence lives at the sink; CSP and cookie flags only limit the damage when a sink is missed.

The three classes

ClassWhere the payload livesTypical exampleWho sees it
StoredDatabase, files, logs, then renderedComment, profile name, ticket body, uploaded SVGEveryone who views the page
ReflectedThe request itself, echoed in the responseSearch term or error message echoed from a query parameterWhoever follows a crafted link
DOM-basedClient-side code moves a source to a sinklocation.hash written with innerHTMLWhoever 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.

Advertisement

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 contextExample templateCorrect 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 evalonclick="go({{x}})"Do not put data here; attach handlers in code
CSSstyle="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:

FrameworkSafe by defaultEscape hatch to audit
ReactJSX text and attribute valuesdangerouslySetInnerHTML, user-controlled href values
AngularInterpolation and built-in sanitiserbypassSecurityTrustHtml and related methods
VueMustache interpolationv-html
Jinja2 / DjangoAutoescape in HTML templates|safe, Markup(), mark_safe
Server-side JSEJS <%= %>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 throw

A 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:

FailureWhy it slips through
Admin dashboard renders customer dataInternal tools skip the framework or review
Encoding for the wrong contextHTML encoding applied to URLs or script blocks
Sanitise, then transformMarkdown or templating after sanitising reintroduces markup
Double decodingInput decoded again after validation
Uploaded SVG or HTML served inlineFiles on the main origin execute as pages
DOM XSS missed by scannersFragment payloads never reach server logs or WAFs

What to do next

  1. Inventory sinks: grep for innerHTML, outerHTML, insertAdjacentHTML, document.write, eval and every framework escape hatch, and assign an owner to each.
  2. Make sure every server-rendered template autoescapes, and remove string-concatenated HTML.
  3. Validate user-supplied URLs against an allow-list of schemes before they reach href or src.
  4. Route all rich HTML through one maintained sanitiser, called immediately before the sink.
  5. Serve user uploads from a separate origin or as attachments.
  6. Deploy a nonce-based CSP in report-only mode, fix the reports, then enforce it; add Trusted Types once your sinks are under policies.
  7. Add payload regression tests for each renderer and run SAST rules for source-to-sink flows in CI.
Key takeaway: XSS is untrusted data reaching an executable sink in your origin, and the fix lives at the sink: encode for the exact output context, validate URL schemes, write text with safe DOM APIs, and sanitise rich HTML with a maintained parser-based sanitiser immediately before rendering. Treat framework escape hatches and internal admin tools as the usual sources, test DOM-based flows that servers never see, and add Trusted Types, a nonce-based CSP and HttpOnly cookies to limit the damage when one sink is missed.