completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

XSS & safe DOM updates

Learn how XSS turns untrusted data into code, then prevent it with textContent, escaping, sanitizers, Trusted Types, and CSP.

By the end, you can
  • 01
    Find the risky pathsName where untrusted data comes into a page, and which sinks can turn it into code.
  • 02
    Show data safelyPick the right defense for each place: textContent, a URL check, escaping, or a sanitizer.
  • 03
    Add browser backupsExplain how Trusted Types and Content Security Policy limit the damage when a mistake slips through.

What XSS means

XSS is short for cross-site scripting. It is a security bug in a web page. It happens when text from outside your code gets treated as code by the browser.

Text that comes from outside your code is called untrusted data. You don't control what it says. Some examples:

  • what someone typed in a search box or a form
  • a comment or a profile field that another user wrote
  • data from an API
  • the part of the URL after #, called the hash
  • a draft saved in storage, or a message from another window

This text should stay plain data. But some browser features read a string as HTML, as a link, or as JavaScript. If untrusted data reaches one of those features, the browser may:

  • build new elements on the page
  • run event handlers, such as onclick
  • follow a javascript: link, which runs code
  • run the string as JavaScript
Plain definition

XSS is a bug where data turns into code. Text you don't control gets read as HTML, as JavaScript, or as another instruction the browser carries out.

Only test pages you own or have permission to test. Every demo in this lesson is harmless. It sets a flag inside a sandbox (a locked-off mini page) or prints some text. Nothing in it does real damage.

Why does it matter? If someone else's script runs in a user's page, it can:

  • read any data the page can read
  • do things as that user, such as send a form
  • change what the page shows, so the user trusts something false
  • steal login tokens that JavaScript can reach

So the rule for this whole module is: never trust input. Check it, escape it, clean it, and limit what the browser is allowed to run.

Real-life analogyPrint a label or follow instructions

A label printer prints whatever words you type. It never acts on them. A helper who reads orders does what the words say. Give plain words to the label printer. Only give words to the helper after you have checked them.

In real life: A label printer prints the words you type
In JavaScript: textContent shows a value as text
In real life: A helper reads the words as orders and does them
In JavaScript: innerHTML reads a string as HTML and builds it
In real life: Each job needs the right helper
In JavaScript: Each place your text goes needs its own defense

Sources, sinks, and safe text

SANDBOXED DEMO

To find XSS, follow the data from where it comes in to where it ends up. Two words help:

  • A source is a place where untrusted data comes into your code.
  • A sink is a browser feature that reads a string as HTML, as a link, or as JavaScript.

Common sources are values in the URL (after ? or #), form fields, API responses, postMessage data (messages from other windows), localStorage, pasted text, and anything another user saved.

Where untrusted data comes in, and where it is dangerousJavaScript
// Sources: places untrusted data can enter your code.const fromUrl = new URLSearchParams(location.search).get("q");const fromHash = location.hash.slice(1);const fromStorage = localStorage.getItem("draft-comment");const fromMessage = event.data; // only after checking event.originconst fromApi = await response.json(); // Dangerous sinks: strings become code or markup.output.innerHTML = fromUrl;card.outerHTML = fromApi.html;panel.insertAdjacentHTML("beforeend", fromStorage);document.write(fromHash);link.href = fromUrl; // dangerous when the scheme is javascript:setTimeout(fromMessage, 0); // string form evaluates code

Lines 2 to 6 are sources. Lines 9 to 14 are dangerous sinks. Each sink line sends a source straight in, with no check. This block only lists names, so it doesn't run.

XSS has three names, based on the path the data takes:

  • Reflected XSS: the server takes a value from the request and sends it straight back in the page.
  • Stored XSS: the value is saved first, for example in a database. Later it is shown to this user or to another one.
  • DOM-based XSS: JavaScript in the browser reads a source, such as the URL hash, and puts it into a sink.

Saving text in a database does not make it safe. Escape it or clean it each time you show it, based on where it goes.

Three data paths, kept as plain dataPop out in the code editor (opens in a new tab)JavaScript
const request = { search: "tea" };console.log("reflected:", request.search); const savedComment = { body: "Nice tea" };console.log("stored:", savedComment.body); const fakeLocation = { hash: "#tea" };const results = { textContent: fakeLocation.hash.slice(1) };console.log("DOM-based:", results.textContent);

Each group of lines models one path:

  • Lines 1 and 2: a request value that is sent back (reflected).
  • Lines 4 and 5: a saved comment loaded for a reader (stored).
  • Lines 7 to 9: browser code reads a hash and puts it in a text-only result (DOM-based).

The output is reflected: tea, stored: Nice tea, then DOM-based: tea. These are plain objects that only model the paths. In a real app, a server handles the request and the saving.

Now compare two sinks. textContent is a safe sink: it shows a string as text. innerHTML is a dangerous sink: it reads a string as HTML and builds real elements.

One string with a button tag and an onclick handler goes to two places. With textContent, the page shows the characters as text. With innerHTML, the page builds a real Run handler button, and clicking it runs the handler.<button onclick=…>textContent<button onclick=…>shown as textinnerHTMLRun handlerhandler runs
The same string goes to two places. textContent shows the characters as plain text. innerHTML builds a real button, and a click runs its onclick code.

Try it yourself. The playground below sends the same harmless string to different sinks. It runs inside a sandboxed iframe, a mini page that can't touch this lesson page.

Send one string to different sinks
The code the sandbox runsPop out in the code editor (opens in a new tab)JavaScript
const payload = '<button onclick="window.xssRan = true">Run handler</button>';const host = document.querySelector("#comment-output"); window.xssRan = false;host.innerHTML = payload;host.querySelector("button")?.click();console.log("innerHTML handler ran:", window.xssRan === true); window.xssRan = false;host.textContent = payload;host.querySelector("button")?.click();console.log("textContent handler ran:", window.xssRan === true); window.scriptRan = false;host.innerHTML = "<script>window.scriptRan = true;<\/script>";console.log("script inserted with innerHTML ran:", window.scriptRan === true);
What happened in the sandboxinnerHTML with a handler

Waiting for the sandbox to run.

Try it yourself
Pick a sink to test in the sandboxed iframe

The iframe has sandbox="allow-scripts" and nothing else. It can run scripts, but it is cut off from this site, so it can't read CompleteFrontend storage.

innerHTML builds a real button. .click() clicks it, and its harmless onclick handler runs. Result: Waiting for the sandbox to run.

The strings are harmless. They only set a flag inside the sandbox, and the sandbox reports yes or no to this page. Nothing from the test is added to this lesson page.

Modern browsers, including Chrome, don't run a <script> element added with innerHTML. That does not make innerHTML safe. These can still cause harm:

  • event handler attributes, such as onclick or onerror
  • links with a dangerous scheme, such as javascript:
  • unusual SVG and MathML markup
  • markup that changes how the page looks or acts

The demo uses an onclick handler and calls .click() itself, so the result is the same every time. An <img onerror> handler would run later, at a moment you can't predict, which is harder to test.

Safe sink or dangerous sink?
  • node.textContent = userInput
  • node.innerHTML = userInput
  • node.outerHTML = apiHtml
  • node.insertAdjacentHTML('beforeend', html)
  • link.href = userInput
  • iframe.src = userInput
  • eval(userInput)
  • setTimeout(userInput, 0)
Try it yourself
0 of 8 correct

Sort each line by the kind of sink it uses. If a sink reads a string as HTML or as code, untrusted data needs a check before it gets there.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Escape for the right context

STEP THROUGH

A context is the kind of place your text ends up. It can be HTML text, an HTML attribute, a URL, JavaScript, or CSS. Each context has its own special characters.

Sometimes you must build an HTML string from plain text. Then you escape the text. That means you swap each special character for a short code called an entity. For example, < becomes &lt;. The browser shows an entity as the plain character, so it can't start a tag.

For HTML text and for quoted HTML attributes, escape five characters: &, <, >, the double quote ", and the single quote '.

Escape & too, because every entity starts with it. If you swap characters with separate replace calls, swap & first. Otherwise the & inside a new &lt; gets swapped again. The helper below avoids that problem: one replace call handles all five characters in a single pass.

Step through escapeHtml
Step 0 of 6Ready
Your turn: follow the blue line

Step through a real HTML escaper. It is small because it handles one context only: HTML text or a quoted HTML attribute. It is not for URLs, JavaScript, or CSS.

Running in
  1. script
Next: line 1
  "&": "&amp;",  "<": "&lt;",  ">": "&gt;",  '"': "&quot;",  "'": "&#39;",}; function escapeHtml(value) {  return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);} const comment = 'Tom & <img src=x onerror="console.log(\'XSS ran\')">';console.log(escapeHtml(comment));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
A real HTML escaper for text and quoted attributesPop out in the code editor (opens in a new tab)JavaScript
const htmlEscapes = {  "&": "&amp;",  "<": "&lt;",  ">": "&gt;",  '"': "&quot;",  "'": "&#39;",}; function escapeHtml(value) {  return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);} const comment = 'Tom & <img src=x onerror="console.log(\'XSS ran\')">';console.log(escapeHtml(comment));

Lines 1 to 7 list the five characters and their entities. Line 10 uses a regular expression to find each of those characters, and swaps it for its entity. Line 14 prints Tom &amp; &lt;img src=x onerror=&quot;console.log(&#39;XSS ran&#39;)&quot;&gt;.

escapeHtml swaps five characters: & becomes &amp;, < becomes &lt;, > becomes &gt;, the double quote becomes &quot;, and the single quote becomes &#39;.escapeHtml(value)&&amp;<&lt;>&gt;"&quot;'&#39;the page shows each one as the plain character
escapeHtml swaps each special character for a short code called an entity. The page then shows the original character as text, so it can't start a tag.

Escaping only works for one context. HTML text, quoted attributes, URLs, JavaScript strings, and CSS values all have different rules. One helper used everywhere does not make a page safe. It is better to use DOM features that never build a string at all:

  • textContent for text
  • setAttribute for attributes you know are safe
  • classList for classes
  • addEventListener for behavior
Each context needs its own defenseJavaScript
const user = '<img src=x onerror="console.log(1)">'; // HTML body context: encode markup characters.message.innerHTML = escapeHtml(user); // Attribute context: quote the attribute and escape quotes.card.setAttribute("aria-label", user); // URL context: parse and allow only expected schemes.link.href = safeUrlAttribute(userUrl); // JavaScript and CSS contexts: do not build code or style text with user data.button.addEventListener("click", () => save(user));

Read it one context at a time:

  • Line 4 puts text into HTML, so it escapes the markup characters first.
  • Line 7 sets an attribute with setAttribute, which stores the value as text. No quote can break out.
  • Line 10 sets a link, so it parses the URL and allows only the schemes it expects.
  • Line 13 needs behavior. It never builds code or styles from user text; it passes the value to a function.

URLs need parsing, not a check of how the string starts. A URL's scheme is the part before the colon, such as https: or mailto:. Parse the value with the URL object and a known base URL. Then allow only the schemes your feature needs. In this lesson, the allowed list is http:, https:, and mailto:. Reject everything else: javascript:, data:, unknown schemes, and values that fail to parse.

Check the URL scheme before setting href or srcPop out in the code editor (opens in a new tab)JavaScript
function safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") {  try {    const url = new URL(raw, base);    if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href;  } catch {    return "about:blank";  }  return "about:blank";} console.log(safeUrlAttribute("/profile?user=ada"));console.log(safeUrlAttribute("mailto:help@example.com"));console.log(safeUrlAttribute("javascript:console.log('XSS ran')"));

Line 3 parses the value. Line 4 returns the full URL only if its scheme is on the list. Lines 11 to 13 print https://completefrontend.com/profile?user=ada, mailto:help@example.com, then about:blank. about:blank is an empty page that does nothing, so it is a safe fallback.

HTML escaping also works inside a quoted HTML attribute. It only fits attributes that hold plain text, such as a label. Line 7 below escapes the label while it builds the button string. When you can, use setAttribute for a text attribute instead. Remember: escaping does not make a URL safe, and setAttribute doesn't check a URL either.

Escape a label for a quoted attributePop out in the code editor (opens in a new tab)JavaScript
function escapeHtmlAttribute(value) {  const entities = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };  return String(value).replace(/[&<>"']/g, (character) => entities[character]);} const label = 'Asha says "tea" & coffee';console.log('<button aria-label="' + escapeHtmlAttribute(label) + '">Open</button>');

The output is <button aria-label="Asha says &quot;tea&quot; &amp; coffee">Open</button>. The quotes and the & inside the label are now entities, so they can't end the quoted attribute. Don't reuse this output in a URL, in JavaScript, or in CSS.

Allow only web links for a profilePop out in the code editor (opens in a new tab)JavaScript
function safeProfileHref(raw, base = "https://completefrontend.com/") {  const url = new URL(raw, base);  return ["http:", "https:"].includes(url.protocol) ? url.href : "about:blank";} console.log(safeProfileHref("/members/asha"));console.log(safeProfileHref("javascript:"));

The first call prints https://completefrontend.com/members/asha. The second prints about:blank, because javascript: is not on the list. The list checks the scheme only, not the site. If links must stay on your own site, also compare url.origin (the scheme plus the site name) with your own origin.

Best default: no HTML string

The safest way to show data is often to build the elements yourself, with createElement, textContent, and attributes you know are safe. Review modifying the DOM and attributes and properties when you need to build elements by hand.

Sanitize HTML when markup is required

ALLOWLIST

Sometimes a feature really does allow markup. Examples are rich-text comments, formatted help text, link previews, or a profile bio with bold text and links. Escaping would show the tags as text instead of formatting them. In that case you need a sanitizer. A sanitizer:

  • parses the HTML into elements, the way the browser does
  • keeps only the tags on an allowlist, a short list of things that are allowed
  • removes unsafe attributes, such as onclick
  • checks the scheme of every link
  • turns the clean result back into HTML text
DOMPurify with your own list of allowed tagsJavaScript
import DOMPurify from "dompurify"; const clean = DOMPurify.sanitize(dirtyHtml, {  ALLOWED_TAGS: ["b", "i", "a", "p", "ul", "li"],  ALLOWED_ATTR: ["href"],}); preview.innerHTML = clean;

DOMPurify is a well-known sanitizer library that its authors keep up to date. Lines 3 to 5 clean the HTML. They allow six tags and one attribute, href. DOMPurify still checks link schemes and removes dangerous ones. Line 8 inserts the clean result.

DOMPurify is not installed on this site, so this example can't run here. There are two ways to choose the allowed HTML: a ready-made profile, such as USE_PROFILES, or your own tag list, as here. Pick one; don't mix them without a reason.

Use DOMPurify inside a Trusted Types policyJavaScript
const policy = trustedTypes.createPolicy("comments", {  createHTML(dirtyHtml) {    return DOMPurify.sanitize(dirtyHtml, {      USE_PROFILES: { html: true },      RETURN_TRUSTED_TYPE: false,    });  },}); preview.innerHTML = policy.createHTML(dirtyHtml);

Here DOMPurify runs inside createHTML, a policy method you'll meet in the Trusted Types section below. RETURN_TRUSTED_TYPE: false on line 5 tells DOMPurify to return a plain string. The policy then wraps that string as TrustedHTML itself. Use the sanitizer's result as it is. Don't change the string after it.

Built-in Sanitizer API status

Element.setHTML() is the browser's own safe way to insert HTML. It is part of the HTML Sanitizer API. It cleans the HTML by default. It always removes elements and attributes that can run code, even if your own settings allow them.

MDN's support data lists it in Chromium 146 and Firefox 148, but not in Safari. So it doesn't work everywhere yet. Check that it exists before you use it. Keep DOMPurify for older browsers and for browsers built into apps.

Check for Element.setHTML before using itPop out in the code editor (opens in a new tab)JavaScript
const target = document.querySelector("#rich-output");const dirty = "<b>Hello</b> <i>friend</i>"; if (typeof target.setHTML === "function") {  target.setHTML(dirty);  console.log("safe text:", target.textContent);} else {  console.log("setHTML unavailable: use DOMPurify or another sanitizer");}

Line 4 checks that setHTML exists. Line 5 inserts the HTML safely, and line 6 prints the text that is now on the page. Line 8 runs in browsers that don't have setHTML.

Three warnings:

  • setHTML() is safe. setHTMLUnsafe() is a different method that does not clean. Don't switch to it just to keep active content.
  • After cleaning, don't turn the result into a string and insert it again with innerHTML. Use a safe insert method again.
  • Don't clean HTML with a regex. HTML has attributes, entities, nested tags, and rules for fixing broken markup. A regex can't follow all of them. See when not to use a regex.
A regex that misses the dangerous attributePop out in the code editor (opens in a new tab)JavaScript
const dirty = '<img src=x onerror="console.log(1)"><scr' + 'ipt>console.log(2)</scr' + 'ipt>';const withoutScripts = dirty.replace(/<script.*?>.*?<\/script>/gi, "");console.log(withoutScripts);

Line 2 removes the <script> tags. Line 3 prints <img src=x onerror="console.log(1)">. The image and its onerror handler are still there.

A real sanitizer works on parsed elements, not on text. Step through the decisions of a small teaching sanitizer:

Step through an allowlist sanitizer
Step 0 of 7Ready
Your turn: follow the blue line

Step through the decisions of the teaching sanitizer. It is an allowlist example for learning. On a real site, use a well-kept sanitizer such as DOMPurify.

Running in
  1. script
Next: line 1
const allowedSchemes = new Set(["http:", "https:", "mailto:"]);const sample = 'Hello <b onclick="x()">bold</b> <a href="javascript:alert(1)">bad</a> <a href="https://example.com">ok</a>'; function sanitizeTeachingHtml(html) {  const parsed = parseToNodes(html);  const cleanNodes = parsed.flatMap(cleanNode);  return cleanNodes.join("");} console.log(sanitizeTeachingHtml(sample)); function parseToNodes(_html) {  return [    { type: "text", text: "Hello " },    { type: "element", tag: "B", children: [{ type: "text", text: "bold" }] },    { type: "text", text: " " },    { type: "element", tag: "A", href: "javascript:alert(1)", children: [{ type: "text", text: "bad" }] },    { type: "text", text: " " },    { type: "element", tag: "A", href: "https://example.com", children: [{ type: "text", text: "ok" }] },  ];} function cleanNode(node) {  if (node.type === "text") return [node.text];  if (!allowedTags.has(node.tag)) return node.children.flatMap(cleanNode);  const href = node.tag === "A" && allowedUrl(node.href) ? ` href="${node.href}" rel="noopener noreferrer"` : "";  const body = node.children.flatMap(cleanNode).join("");  return [`<${node.tag.toLowerCase()}${href}>${body}</${node.tag.toLowerCase()}>`];} function allowedUrl(value) {  try {    return allowedSchemes.has(new URL(value, "https://example.com/").protocol);  } catch {    return false;  }}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Before and after the teaching sanitizer. A b tag with onclick becomes a plain b tag. A link with a javascript: href becomes a link with no href. A link with an https href keeps its href and gets a rel attribute.beforeafter<b onclick="x()"><b>onclick gone<a href="javascript:…"><a>href gone<a href="https://…"><a href="https://…">href keptrel added
The sanitizer keeps the allowed tags <b> and <a>. It drops onclick and the javascript: link, and keeps the https: link.

Here is the full teaching sanitizer, which runs in the browser:

Teaching sanitizer with DOMParser and a templatePop out in the code editor (opens in a new tab)JavaScript
function isSafeUrl(value) {  try {    const url = new URL(value, "https://example.com/");    return ["http:", "https:", "mailto:"].includes(url.protocol);  } catch {    return false;  }} function sanitizeTeachingHtml(dirtyHtml) {  const allowedTags = new Set(["B", "I", "A"]);  const dropWithChildren = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "SVG", "MATH"]);  const parser = new DOMParser();  const parsed = parser.parseFromString(dirtyHtml, "text/html");  const output = document.createElement("template");   function cleanInto(node, parent) {    if (node.nodeType === Node.TEXT_NODE) {      parent.append(document.createTextNode(node.textContent ?? ""));      return;    }    if (node.nodeType !== Node.ELEMENT_NODE) return;    if (dropWithChildren.has(node.tagName)) return;    if (!allowedTags.has(node.tagName)) {      node.childNodes.forEach((child) => cleanInto(child, parent));      return;    }     const clean = document.createElement(node.tagName.toLowerCase());    if (node.tagName === "A") {      const href = node.getAttribute("href")?.trim() ?? "";      if (isSafeUrl(href)) {        clean.setAttribute("href", href);        clean.setAttribute("rel", "noopener noreferrer");      }    }    node.childNodes.forEach((child) => cleanInto(child, clean));    parent.append(clean);  }   parsed.body.childNodes.forEach((child) => cleanInto(child, output.content));  const wrapper = document.createElement("div");  wrapper.append(output.content.cloneNode(true));  return wrapper.innerHTML;} const dirty = 'Hello <b onclick="x()">bold</b> <i>friend</i> ' +  '<a href="javascript:alert(1)" onclick="x()">bad</a> ' +  '<a href="https://example.com">ok</a>' +  '<' + 'script>console.log("x")</' + 'script><img src=x onerror="x()">';console.log(sanitizeTeachingHtml(dirty));

The main lines, in order:

  1. Lines 11 and 12 list the allowed tags, and the dangerous tags to drop with everything inside them.
  2. Lines 13 and 14 parse the HTML with DOMParser, the browser's own HTML reader. Nothing on the page runs it.
  3. Line 18 copies text as plain text.
  4. Line 23 drops dangerous tags, such as <script>, with their content.
  5. Line 24 handles any other tag that is not on the list: it keeps only the text inside.
  6. Line 29 makes a fresh, empty copy of an allowed tag, so no attributes come along.
  7. Line 32 keeps a link's href only if its scheme is safe.
  8. Line 44 returns the clean HTML as a string.

Line 51 prints Hello <b>bold</b> <i>friend</i> <a>bad</a> <a href="https://example.com" rel="noopener noreferrer">ok</a>. The script and the image are gone.

Teaching example, not production code

The sanitizer above is real code for learning. It parses, copies allowed tags, removes attributes, and checks links. But no security expert has tested it against attacks. For a real site, use a sanitizer that experts keep up to date.

Each row below is one way to handle HTML from users. Read across to see what it looks like and when to use it.

Ways to handle HTML from users
Which approach?What does it look like?When should you use it?
A sanitizer libraryDOMPurify.sanitize(dirty, config)Use it on most sites. Experts keep it up to date, and it works in most browsers. You still need the right settings, and you must not change its result afterward.
The built-in Sanitizer APIelement.setHTML(dirty), when the browser has itCheck that it exists first. Not every browser has it yet, especially older browsers and browsers built into apps.
A regex replacehtml.replace(/<script.*>/g, '')Never. It is not a sanitizer. The browser's rules for reading HTML are too complex for a regex.
The teaching sanitizerThe small allowlist sanitizer in this lessonOnly for learning the decisions. No security expert has tested it.

Trusted Types

CSP GUARDRAIL

Trusted Types is a browser guard against DOM-based XSS. You turn it on with a CSP rule, which the next section explains: require-trusted-types-for 'script'. Then dangerous sinks such as innerHTML refuse plain strings. They only accept a TrustedHTML object.

A TrustedHTML object comes from a policy. A policy is a small named object that you create. Its job is to check or clean the HTML before it reaches a sink.

A plain string fails when Trusted Types is onPop out in the code editor (opens in a new tab)JavaScript
if (!window.trustedTypes) {  throw new Error("Trusted Types are not available in this browser");} const target = document.querySelector("#trusted-target");try {  target.innerHTML = "<b>plain string</b>";  throw new Error("Trusted Types enforcement is not active");} catch (error) {  console.log(error.name);  throw error;}

Line 7 sets innerHTML to a plain string. With Trusted Types on, the browser throws a TypeError. Line 10 prints its name, and line 11 throws it again. If the browser doesn't enforce Trusted Types, line 8 throws an error that says so.

A policy that shows the parts (it does not clean)Pop out in the code editor (opens in a new tab)JavaScript
if (window.trustedTypes) {  const policy = trustedTypes.createPolicy("lesson", {    createHTML(value) {      return value;    },  });   const target = document.querySelector("#trusted-target");  target.innerHTML = policy.createHTML("<b>Trusted Types policy output</b>");  console.log(target.textContent);} else {  console.log("Trusted Types unavailable: keep escaping and sanitizing");}

Line 2 creates a policy named lesson. Its createHTML method, on line 3, returns the value it gets. Line 9 passes the policy's TrustedHTML to innerHTML, and the browser accepts it. Line 10 prints Trusted Types policy output.

With Trusted Types turned on: a plain string sent to innerHTML throws a TypeError. A value from policy.createHTML is TrustedHTML, and innerHTML accepts it.a plain string"<b>plain string</b>"innerHTMLTypeErrorTrustedHTML from the policypolicy.createHTML(…)innerHTMLaccepted
With Trusted Types on, innerHTML throws a TypeError for a plain string. It accepts a TrustedHTML value made by policy.createHTML().

This policy returns its input unchanged, only to show how the parts fit. It is not safe for untrusted data. A real policy must clean the HTML, as in the DOMPurify example above. Trusted Types does not clean anything by itself.

More browsers support Trusted Types than the Sanitizer API. But older browsers and browsers built into apps may not. It only works when the browser follows the CSP rule.

Content Security Policy

DEFENSE IN DEPTH

Content Security Policy, or CSP, is a list of rules that tells the browser what a page may load and run. The server sends it with the page. The best way is an HTTP response header: a line of extra information that arrives with the page. A meta tag in the HTML can set some rules too. But it can't set frame-ancestors, and it can't turn on report-only mode.

CSP is a backup layer. It limits the damage when a bug slips through. It does not replace escaping, URL checks, sanitizing, or careful code review.

A strong CSP to start fromtext
Content-Security-Policy:  default-src 'self';  script-src 'nonce-{fresh-random-value-per-response}' 'strict-dynamic';  object-src 'none';  base-uri 'self';  frame-ancestors 'none';  require-trusted-types-for 'script';  trusted-types lesson dompurify;

What each line does:

  • Line 2, default-src, is the fallback rule for anything the other lines don't name.
  • Line 3, script-src, allows only scripts that carry the right nonce. A nonce is a random value. The server makes a new one for each response and puts it on its own script tags. Never hard-code a nonce or reuse one. 'strict-dynamic' lets those trusted scripts load more scripts.
  • Line 4, object-src 'none', blocks old plugin content.
  • Line 5, base-uri 'self', stops an injected <base> tag from changing where relative links point.
  • Line 6, frame-ancestors, controls which sites may show your page inside a frame.
  • Line 7, require-trusted-types-for 'script', turns on the Trusted Types checks.
  • Line 8, trusted-types, lists the policy names the page may create.
Avoid unsafe-inline

'unsafe-inline' in script-src allows scripts written inside the page and inline event handlers such as onclick. That removes one of CSP's strongest protections against XSS. Use nonces or hashes instead. A hash is a short fingerprint of one script's exact text.

To switch on a policy safely, start with Content-Security-Policy-Report-Only. Read the reports, fix the problems, then enforce the policy.

Watch for rule breaks before you enforcetext
Content-Security-Policy-Report-Only:  default-src 'self';  script-src 'self';

The Content-Security-Policy-Report-Only header reports rule breaks but does not block them. To receive the reports, set up a reporting endpoint: an address on your server that collects them. Watch real traffic and fix the gaps. Then send the enforcing Content-Security-Policy header.

Real-world patterns

Many features show data that someone else can change. Some examples are comment widgets, search results, rich-text editors, link previews, support inboxes, notification templates, and dashboards. The steps are the same every time:

  1. Find the source of the data.
  2. Pick the safest sink.
  3. Check every URL.
  4. Sanitize only when markup is required.
  5. Add Trusted Types and CSP, so mistakes get caught.
Framework text is escaped; escape hatches are notJSX
// React escapes text in JSX by default.function Comment({ author, body }) {  return <article><h3>{author}</h3><p>{body}</p></article>;} // Escape hatches put you back in charge of sanitizing.function RichComment({ sanitizedHtml }) {  return <article dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;} // Vue's {{ message }} escapes. v-html renders HTML and needs sanitized input.

Line 3 puts author and body into JSX as text, so React escapes them. Line 8 uses dangerouslySetInnerHTML, which inserts HTML with no escaping. Its HTML must be sanitized first. Line 11 says the same for Vue.

Text bindings and raw HTML in three frameworkstext
React text: <p>{comment}</p>React raw HTML: <div dangerouslySetInnerHTML={{ __html: reviewedHtml }} /> Vue text: <p>{{ comment }}</p>Vue raw HTML: <div v-html="reviewedHtml"></div> Svelte text: <p>{comment}</p>Svelte raw HTML: {@html reviewedHtml}

In React, Vue, and Svelte, ordinary text bindings escape markup for you. Each framework also has a raw HTML escape hatch: a feature that skips the escaping on purpose. Lines 2, 5, and 8 show them. An escape hatch needs sanitized HTML.

Frameworks also don't check link schemes for you. Code that touches the DOM directly, or builds HTML strings, needs the same source-to-sink check. When you build your own rendering code, review template literals, eval and dynamic code, postMessage origin checks, web storage, and the tiny reactive UI lesson.

Each card below is a short checklist for one kind of feature.

Plain text on the page

Use textContent, JSX text, or a similar safe binding. Don't parse HTML just to show a name, a search term, or a status.

Links from users

Parse with new URL() and allow only known schemes. Think about which sites you allow. Add rel="noopener noreferrer" to links that go to other sites.

Rich text

Clean it with a well-kept sanitizer and a short allowlist. Keep the original text too, so you can clean it again when your rules change.

The whole app

Turn on CSP reports, remove inline handlers, and use Trusted Types. Keep secrets where JavaScript can't read them, unless the app truly needs them there.

Common misconceptions

  • "Scripts added with innerHTML don't run, so innerHTML is safe." Not true. Event handler attributes, dangerous links, and other active markup still work.
  • "Escaping once makes data safe everywhere." Not true. Escaping works for one context only. HTML, URLs, JavaScript, and CSS each have their own rules.
  • "Sanitizing means removing script tags." Not true. A sanitizer needs an allowlist for tags, attributes, and URL schemes. Removing tags with a regex is not enough.
  • "Trusted Types cleans HTML for me." Not true. Trusted Types only refuses plain strings. Your policy must still clean the HTML or build it safely.
  • "CSP fixes XSS." Not true. CSP limits what can run and reports rule breaks. It is a backup layer, not the main defense.
  • "Frameworks stop all XSS." Not true. Their automatic escaping helps until you use an escape hatch, direct DOM access, an unchecked URL, or an HTML string.

Each row below is one defense. Read across to see what it does and when to use it.

Which defense does which job?
Which defense?What does it do?When should you use it?
textContent or innerTextIt puts the value on the page as text, never as HTML.Use it first, for comments, search results, labels, and strings from an API.
HTML escapingIt swaps <, >, &, and quotes for entities, for one HTML context.Use it when you must build an HTML string from plain text.
SanitizerIt parses HTML and keeps only allowed tags, attributes, and link schemes.Use it for rich-text editors, bios, formatted comments, or content from a site editor.
Trusted TypesIt makes dangerous sinks refuse plain strings and accept only TrustedHTML from a policy.Use it so that a stray innerHTML = string fails, like a lock that won't open without the right key.
CSPIt limits which scripts, plugins, base URLs, and frames the page may use, and reports rule breaks.Use it as a backup layer. It limits the damage; it doesn't replace safe sinks.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upFix a plain comment renderer

This function shows a user's comment. Replace the dangerous sink with the safest property for plain text.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function renderComment(container, comment) {
  container.innerHTML = comment;
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the escaped output

    Type the exact string that the escaper prints.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const htmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
    function escapeHtml(value) {
      return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);
    }
    console.log(escapeHtml('<b title="Tom & Jerry">Hi</b>'));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeReject a script URL

      Predict what the guard returns for a dangerous scheme.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") {
        try {
          const url = new URL(raw, base);
          if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href;
        } catch {}
        return "about:blank";
      }
      console.log(safeUrlAttribute("javascript:alert(1)"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeName the Trusted Types policy method

        Name the policy method that makes checked HTML for dangerous sinks.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const policy = trustedTypes.createPolicy("comments", {
          // Which method returns checked TrustedHTML?
        });

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind the DOM-based XSS in a hash reader

          This function shows the search term from the URL hash. Name the dangerous sink.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function showSearchFromHash() {
            const query = decodeURIComponent(location.hash.slice(1));
            results.innerHTML = "Search: " + query;
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            XSS and safe DOM updates quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is cross-site scripting (XSS)?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which line is the safe default for showing a plain user comment?

              Choose an answer to see the explanation.

            3. Question 3 of 8What does this URL guard print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") {
                try {
                  const url = new URL(raw, base);
                  if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href;
                } catch {}
                return "about:blank";
              }
              console.log(safeUrlAttribute("javascript:console.log('XSS ran')"));

              Choose an answer to see the explanation.

            4. Question 4 of 8Why can't the DOMPurify example run in this lesson?

              Choose an answer to see the explanation.

            5. Question 5 of 8With require-trusted-types-for 'script' turned on, what happens to element.innerHTML = '<b>x</b>'?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which CSP choice weakens your XSS defense the most?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does this code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const htmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
              function escapeHtml(value) {
                return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);
              }
              console.log(escapeHtml('<img src=x onerror="console.log(1)">'));

              Choose an answer to see the explanation.

            8. Question 8 of 8Which sentence about frameworks is true?

              Choose an answer to see the explanation.

            Key takeaways

            • XSS is a bug where data turns into code. Start every check by following untrusted data from its source to its sink.
            • For plain text, use textContent, safe framework bindings, or elements you build yourself. Avoid HTML strings when you can.
            • Escape for the exact context. Check URLs with new URL() and a list of allowed schemes.
            • When users may send HTML, use a well-kept sanitizer with an allowlist. The teaching sanitizer only shows the decisions.
            • Trusted Types makes dangerous sinks refuse plain strings, and CSP limits what can run. Both are backup layers, not a replacement for safe sinks.

            One-line summary: keep untrusted data from running as code: pick safe sinks first, sanitize only when markup is required, and add browser rules as a backup.

            Up next: CSRF, cookies, and tokens. This lesson stops other people's code from running in your page. The next one protects the requests a logged-in user sends.

            CompleteFrontend Clear concepts. Working examples.