Back to Security
Security
easy
mid

How do you avoid common XSS pitfalls in modern frontend frameworks?

Default: let the framework escape ({value} in React, {{value}} in Vue/Angular). Never use dangerouslySetInnerHTML/v-html/innerHTML with untrusted content unless sanitized through DOMPurify with a strict allowlist. Validate URL schemes (block javascript:) before rendering as href/src. Never eval user input. Use CSP as defense-in-depth. For rich-text editors, sanitize at storage (not just display) and consider Trusted Types in Chromium.

9 min read·~15 min to think through

XSS-safe rendering means user-controlled content can never become executable script in another user's browser. Two layers: avoid dangerous APIs by default, and sanitize when you must.

Rule 1: Trust the framework's escaping

Modern frameworks escape interpolated values automatically:

jsx
// React — escaped
<div>{userInput}</div>
html
<!-- Vue — escaped -->
<div>{{ userInput }}</div>
html
<!-- Angular — escaped -->
<div>{{ userInput }}</div>

User submits <script>alert(1)</script> → renders as text (&lt;script&gt;...). Safe.

Rule 2: Don't reach for the dangerous APIs

React: dangerouslySetInnerHTML. Vue: v-html. Angular: [innerHTML]. Vanilla: innerHTML, outerHTML, document.write, insertAdjacentHTML.

Each of these bypasses escaping. If you write user input through them, that input becomes parsed HTML — including scripts.

If you don't need HTML output, use textContent / interpolation. Most content is plain text or markdown — render markdown via a library that emits safe HTML.

Rule 3: When you must render HTML, sanitize

For rich-text editor output, comment HTML, paste-from-Word content:

js
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(userHtml, {
  ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'blockquote', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'title'],
  ALLOWED_URI_REGEXP: /^(?:https?:\/\/|mailto:|tel:|\/)/,
});

container.innerHTML = clean;

DOMPurify is battle-tested. It handles weird edge cases — SVG <use> xlink:href injection, mutation XSS, attribute parser tricks — that a regex-based sanitizer would miss.

Sanitize at storage, not just at display. Otherwise, the next consumer (mobile app, RSS, export) gets raw HTML.

Rule 4: Validate URLs before using as href/src

Attacker injects <a href="javascript:alert(1)">. The framework's HTML escape doesn't help — the URL itself is the script.

js
function isSafeUrl(url: string): boolean {
  try {
    const u = new URL(url, window.location.origin);
    return ['http:', 'https:', 'mailto:', 'tel:'].includes(u.protocol);
  } catch { return false; }
}

<a href={isSafeUrl(url) ? url : '#'}>...</a>

React 17+ warns on javascript: URIs but doesn't block them in all cases.

Rule 5: Never eval user input

eval(), new Function(), setTimeout('code', ...), setInterval('code', ...) — all parse strings as JS. Any user-controlled input there is RCE.

Same for <script> tags inserted via JS, even if you swear the content is "safe."

Rule 6: CSP as defense-in-depth

Even with perfect output handling, a regression slips through eventually. CSP limits damage:

ts
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';

A successful XSS that injects <script>steal()</script> is blocked because the script lacks the nonce.

Rule 7: HttpOnly + Secure + SameSite cookies

Even if XSS happens, HttpOnly cookies aren't readable from JS — session token isn't stealable via document.cookie. Combined with short token lifetimes, the blast radius of XSS shrinks dramatically.

Rule 8: Trusted Types (Chromium)

ts
Content-Security-Policy: require-trusted-types-for 'script'

Forces dangerous DOM sinks (innerHTML, document.write) to require a TrustedHTML object. Plain strings are rejected at runtime. Eliminates entire classes of DOM-based XSS.

Rule 9: Watch out for less-obvious sinks

  • <style> content can include expression() in old IE (mostly historical).
  • SVG inside HTML can carry <script> or event handlers.
  • postMessage between windows — validate origin and structure.
  • WebSocket message rendering — same rules as HTTP.
  • Markdown renderers: configure to strip raw HTML.
  • Image onerror handlers: <img src=x onerror=alert(1)> — never let user-supplied HTML through unsanitized.

Rule 10: Test it

  • Manually try injecting <img src=x onerror=alert(1)> and javascript:alert(1) in all input fields.
  • Use ZAP, Burp, or similar to fuzz.
  • Code review for any direct innerHTML / dangerouslySetInnerHTML usage.
  • Add lint rule (eslint-plugin-react/no-danger) to flag the dangerous API.

Pitfalls

  • Trusting the framework but using innerHTML anyway.
  • Sanitizing at display but not at storage — next renderer gets raw HTML.
  • Regex-based "sanitizers" — always bypassed by clever payloads.
  • Allowing href without scheme validation.
  • Markdown renderers that pass HTML through by default.
  • Server-side templates that auto-escape but you opt out for "safe" content.
  • Trusting third-party widgets' HTML output without sanitization.

Mental model

XSS-safe rendering = let the framework escape by default; sanitize HTML with a battle-tested library when you must accept it; validate URL schemes; never eval. Layer with CSP, HttpOnly cookies, and Trusted Types as defense-in-depth.

Follow-up questions

  • Why use DOMPurify instead of a regex sanitizer?
  • How does Trusted Types prevent DOM XSS?
  • What's mutation XSS?
  • How do you safely render markdown?

Common mistakes

  • Using dangerouslySetInnerHTML / v-html / innerHTML with user input.
  • Regex-based 'sanitizers' — always bypassed.
  • Sanitizing at display only, not at storage.
  • Allowing href without scheme validation — javascript: injection.
  • eval() of user input.
  • Markdown renderer config that allows raw HTML.

Performance considerations

  • DOMPurify is fast (<1ms for typical inputs). Sanitization at storage time amortizes the cost across reads. CSP has zero runtime cost. The cost of a XSS incident dwarfs all these.

Edge cases

  • SVG embedded in HTML can carry scripts.
  • innerText is escape-safe; innerHTML is not.
  • postMessage from other windows — validate origin.
  • DOMParser('text/html') is safer for parsing but inserts none of the embedded scripts.
  • Mutation XSS: input that's safe-looking but mutates dangerously when parsed.

Real-world examples

  • GitHub, Stack Overflow — heavy use of DOMPurify-like sanitization for user content.
  • React's default escape behavior catches the most common cases.
  • OWASP Top 10 lists XSS year after year — it's still common.

Senior engineer discussion

Seniors default to letting the framework escape, lint against dangerous APIs, sanitize unavoidable HTML with DOMPurify (with a strict allowlist), validate URLs, and layer CSP + HttpOnly cookies as defense-in-depth. They sanitize at storage AND at display.

Related questions