XSS & safe DOM updates
Learn how XSS turns untrusted data into code, then prevent it with textContent, escaping, sanitizers, Trusted Types, and CSP.
- 01Find the risky pathsName where untrusted data comes into a page, and which sinks can turn it into code.
- 02Show data safelyPick the right defense for each place: textContent, a URL check, escaping, or a sanitizer.
- 03Add 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
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.
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:
textContentshows a value as text - In real life: A helper reads the words as orders and does them
- In JavaScript:
innerHTMLreads 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 DEMOTo 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.
// 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 codeLines 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.
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.
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.
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);Waiting for the sandbox to run.
innerHTML builds a real button. .click() clicks it, and its harmless onclick handler runs. Result: Waiting for the sandbox to run.
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
onclickoronerror - 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.
node.textContent = userInputnode.innerHTML = userInputnode.outerHTML = apiHtmlnode.insertAdjacentHTML('beforeend', html)link.href = userInputiframe.src = userInputeval(userInput)setTimeout(userInput, 0)
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.
Escape for the right context
STEP THROUGHA 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 <. 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 < gets swapped again. The helper below avoids that problem: one replace call handles all five characters in a single pass.
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.
script
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",}; 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));const htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'",}; 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 & <img src=x onerror="console.log('XSS ran')">.
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:
textContentfor textsetAttributefor attributes you know are safeclassListfor classesaddEventListenerfor behavior
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.
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.
function escapeHtmlAttribute(value) { const entities = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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 "tea" & 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.
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.
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
ALLOWLISTSometimes 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
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.
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.
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.
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.
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 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.
script
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; }}<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:
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:
- Lines 11 and 12 list the allowed tags, and the dangerous tags to drop with everything inside them.
- Lines 13 and 14 parse the HTML with
DOMParser, the browser's own HTML reader. Nothing on the page runs it. - Line 18 copies text as plain text.
- Line 23 drops dangerous tags, such as
<script>, with their content. - Line 24 handles any other tag that is not on the list: it keeps only the text inside.
- Line 29 makes a fresh, empty copy of an allowed tag, so no attributes come along.
- Line 32 keeps a link's
hrefonly if its scheme is safe. - 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.
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.
| Which approach? | What does it look like? | When should you use it? |
|---|---|---|
| A sanitizer library | DOMPurify.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 API | element.setHTML(dirty), when the browser has it | Check that it exists first. Not every browser has it yet, especially older browsers and browsers built into apps. |
| A regex replace | html.replace(/<script.*>/g, '') | Never. It is not a sanitizer. The browser's rules for reading HTML are too complex for a regex. |
| The teaching sanitizer | The small allowlist sanitizer in this lesson | Only for learning the decisions. No security expert has tested it. |
Trusted Types
CSP GUARDRAILTrusted 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.
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.
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.
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 DEPTHContent 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.
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.
'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.
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:
- Find the source of the data.
- Pick the safest sink.
- Check every URL.
- Sanitize only when markup is required.
- Add Trusted Types and CSP, so mistakes get caught.
// 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.
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? | What does it do? | When should you use it? |
|---|---|---|
textContent or innerText | It 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 escaping | It swaps <, >, &, and quotes for entities, for one HTML context. | Use it when you must build an HTML string from plain text. |
| Sanitizer | It 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 Types | It 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. |
| CSP | It 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 EXERCISESThis function shows a user's comment. Replace the dangerous sink with the safest property for plain text.
function renderComment(container, comment) {
container.innerHTML = comment;
}function renderComment(container, comment) {
container.textContent = comment;
}textContent shows the comment as text. If the comment contains <img onerror=...>, visitors see those characters. No new element is built.
Type the exact string that the escaper prints.
const htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);
}
console.log(escapeHtml('<b title="Tom & Jerry">Hi</b>'));The output is <b title="Tom & Jerry">Hi</b>. It is safe to put into HTML text.
Predict what the guard returns for a dangerous scheme.
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)"));The guard returns about:blank. A rejected URL becomes an empty page that does nothing, instead of going into href or src.
Name the policy method that makes checked HTML for dangerous sinks.
const policy = trustedTypes.createPolicy("comments", {
// Which method returns checked TrustedHTML?
});const policy = trustedTypes.createPolicy("comments", {
createHTML(value) {
return DOMPurify.sanitize(value, { USE_PROFILES: { html: true } });
},
});createHTML is where HTML gets checked before it reaches a sink such as innerHTML. On a real site it should call a sanitizer, as here.
This function shows the search term from the URL hash. Name the dangerous sink.
function showSearchFromHash() {
const query = decodeURIComponent(location.hash.slice(1));
results.innerHTML = "Search: " + query;
}function showSearchFromHash() {
const query = decodeURIComponent(location.hash.slice(1));
results.textContent = "Search: " + query;
}The dangerous sink is results.innerHTML. Anyone can write a link with any hash, so show the query as text.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is cross-site scripting (XSS)?
Choose an answer to see the explanation.
Question 2 of 8Which line is the safe default for showing a plain user comment?
Choose an answer to see the explanation.
Question 3 of 8What does this URL guard print?
Read the code, then predictfunction 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.
Question 4 of 8Why can't the DOMPurify example run in this lesson?
Choose an answer to see the explanation.
Question 5 of 8With
require-trusted-types-for 'script'turned on, what happens toelement.innerHTML = '<b>x</b>'?Choose an answer to see the explanation.
Question 6 of 8Which CSP choice weakens your XSS defense the most?
Choose an answer to see the explanation.
Question 7 of 8What does this code print?
Read the code, then predictconst htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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.
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.