Safely render dynamic HTML and prevent XSS
Default to text rendering (`textContent`, React's `{value}`) — never `innerHTML` with untrusted input. When you must render HTML, sanitize with DOMPurify on the way *in* and use a strict allow-list. Combine with a Content-Security-Policy that bans inline scripts and `eval`. Treat user-supplied URLs as suspicious — block `javascript:` and `data:` for href/src.
XSS happens when attacker-controlled data is rendered as code. The whole defense is: never let strings cross from data into the executable surface (HTML / JS / event handlers) without a transformation that strips the executable parts.
Three contexts, three rules.
- HTML body text — escape
<,>,&,",'.textContent, React's{value}, Vue's{{ value }}, and template engines with auto-escaping (Handlebars, Jinja) do this. Stay in this lane by default.
- HTML attributes — escape the same characters plus the matching quote. Never build attributes by string concatenation; use
element.setAttributeor framework binding (<a href={url}>in JSX).
- JS / URL contexts —
<a href="{user}">is dangerous ifuserisjavascript:alert(1). Allow-list URL schemes (http,https,mailto). Same forsrc,formaction,xlink:href.
When you must render rich HTML (CMS, comments with formatting, email rendering): sanitize.
import DOMPurify from "dompurify";
const safe = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ["p", "br", "strong", "em", "a", "ul", "ol", "li", "code"],
ALLOWED_ATTR: ["href", "title"],
ALLOWED_URI_REGEXP: /^(https?|mailto):/i,
});
element.innerHTML = safe; // vanilla
<div dangerouslySetInnerHTML={{__html: safe}} /> // ReactWhy DOMPurify, not regex. HTML parsing is non-regular. <<script>script>, malformed nesting, SVG namespaces, mutation XSS — all bypass regex filters. DOMPurify uses the browser's HTML parser then walks the tree and removes anything not on the allow-list.
Sanitize once, on the trusted boundary. Store the sanitized form, or sanitize on the way out of the database render path. Never sanitize on the way in AND on the way out — they can disagree and one will lose. Pick a boundary.
The senior layer: Content-Security-Policy. Even if XSS gets through, a strict CSP makes it unexploitable:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}';
object-src 'none';
base-uri 'self';'nonce-{random}' (re-generated per request) lets your own inline bootstrap scripts run but blocks injected ones. Avoid 'unsafe-inline' and 'unsafe-eval'. Adopt Trusted Types (require-trusted-types-for 'script') on Chromium for an extra mechanical guarantee that nothing can write a string to a sink like innerHTML without going through a vetted policy.
React's "safe by default" — and its gaps.
{value}is always escaped.dangerouslySetInnerHTMLis the explicit opt-out — search your codebase for it.<a href={url}>does NOT validate the URL scheme. Ifurl = "javascript:...", React 16+ blocks it; earlier versions don't. Verify the version or validate explicitly.href={/path/${id}}is fine;href={user.profileUrl}is suspicious.
XSS sinks to grep for. innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, new Function, setTimeout(string,...), location, element.src for scripts, dangerouslySetInnerHTML, v-html, {@html}.
Storage vs reflected vs DOM-based. Storage XSS persists (DB → page); reflected bounces off a URL parameter; DOM-based runs entirely client-side from a sink like location.hash. Sanitization handles storage and reflected. DOM-based requires audit of the sinks above.
Follow-up questions
- •Difference between escaping and sanitization?
- •How does Content-Security-Policy mitigate XSS that gets past sanitization?
- •What is mutation XSS, and why do regex sanitizers fail to stop it?
- •When does React's auto-escaping not protect you?
Common mistakes
- •Using a regex to strip `<script>` tags instead of a real HTML parser.
- •Sanitizing on input AND output — drift between the two opens holes.
- •Allowing `javascript:` in href because you only checked the tag name.
- •Relying on `dangerouslySetInnerHTML` without sanitizing first.
Performance considerations
- •DOMPurify is fast but not free — sanitize once at write time, not on every render.
- •CSP enforcement is essentially free at the browser layer.
Edge cases
- •SVG `<use>` with `xlink:href` to a remote namespace — historically a sanitizer bypass.
- •Mutation XSS — sanitized markup that re-parses into something dangerous when inserted.
- •Markdown renderers — must sanitize the rendered HTML, not the markdown source.
Real-world examples
- •Gmail's renderer for HTML emails is a sanitizer pipeline + CSP.
- •GitHub comments allow a subset of HTML — sanitized server-side before storage.
- •TweetDeck's 2014 mXSS worm was a parser-mismatch bug.