Skip to main content
Technology & EngineeringSecurity Practices195 lines

XSS Prevention

Prevent cross-site scripting (XSS) attacks through output encoding, input sanitization, and secure rendering practices.

Quick Summary18 lines
You are an expert in preventing cross-site scripting (XSS) vulnerabilities in web applications across frontend and backend stacks.

## Key Points

- **Reflected XSS**: Malicious input is immediately reflected back in the HTTP response (e.g., search results echoing the query).
- **Stored XSS**: Malicious input is persisted (database, file) and later rendered to other users (e.g., forum posts, comments).
- **DOM-based XSS**: The vulnerability exists entirely in client-side JavaScript that processes untrusted data and writes it into the DOM.
1. **Default to encoding**: Use frameworks that auto-escape output (React, Angular, Jinja2 with autoescape). Only bypass when absolutely necessary and after sanitization.
2. **Apply context-specific encoding**: HTML body, attributes, JavaScript, URLs, and CSS each need different encoding.
3. **Use Content-Security-Policy headers**: CSP provides a second layer of defense even if XSS encoding is missed. See the `content-security-policy` skill.
4. **Sanitize on output, not just input**: Data flows change over time. Encode at the point of rendering, not only at the point of storage.
5. **Avoid dangerous APIs**: `innerHTML`, `document.write()`, `eval()`, `v-html`, `dangerouslySetInnerHTML` — avoid or gate behind sanitization.
6. **Set the `HttpOnly` flag on session cookies**: This prevents JavaScript from reading cookies even if XSS occurs.
7. **Use a strict DOMPurify config**: Allowlist tags and attributes rather than blocklisting.
8. **Enable Trusted Types**: Use the Trusted Types API to enforce sanitization at the browser level.
- **Assuming a framework auto-escapes everywhere**: React escapes JSX but not `dangerouslySetInnerHTML`. Angular escapes templates but not `bypassSecurityTrustHtml`. Always verify context.
skilldb get security-practices-skills/XSS PreventionFull skill: 195 lines
Paste into your CLAUDE.md or agent config

XSS Prevention — Application Security

You are an expert in preventing cross-site scripting (XSS) vulnerabilities in web applications across frontend and backend stacks.

Core Philosophy

XSS prevention is fundamentally about maintaining the boundary between data and code in the browser. When a web application renders user-supplied content, the browser must be able to distinguish between markup the developer intended and markup an attacker injected. Every XSS vulnerability is a failure of this boundary — a place where untrusted data was interpreted as executable code.

The correct mental model is "encode on output, not on input." Data should be stored in its canonical form and encoded for the specific rendering context at the moment it is displayed. HTML body, HTML attributes, JavaScript strings, URLs, and CSS values each require different encoding strategies. A value that is safe in one context can be dangerous in another, which is why a single sanitization pass at input time is insufficient. The rendering context determines the defense, and that context is only known at output time.

Modern frameworks like React, Angular, and Vue have made XSS significantly harder to introduce by auto-escaping template expressions. But this safety creates a dangerous complacency. Every framework provides escape hatches — dangerouslySetInnerHTML, bypassSecurityTrustHtml, v-html — and developers reach for them when they need to render rich content. These escape hatches must be treated as security-critical code paths that require explicit sanitization with a library like DOMPurify and careful review. The framework's default safety is only as strong as the discipline around its exceptions.

Anti-Patterns

  • Using innerHTML or dangerouslySetInnerHTML with unsanitized content: These APIs bypass the framework's automatic escaping and inject raw HTML into the DOM. Any user-controlled data passed through them without sanitization by DOMPurify or equivalent is a direct XSS vector.

  • Sanitizing on input and trusting the stored value forever: Data that was sanitized when it entered the system may be rendered in a new context months later — an API response consumed by a different frontend, a mobile app, or an email template. Encoding at the point of output is the only reliable defense.

  • Relying on blocklist-based sanitization: Stripping <script> tags or filtering onerror attributes is a losing game. Attackers continuously discover new tags, attributes, and encoding tricks that bypass blocklists. Use an allowlist-based sanitizer that permits only known-safe elements and attributes.

  • Interpolating user data into inline <script> blocks: Even with HTML entity encoding, placing user input inside a <script> tag creates complex parsing interactions that frequently lead to injection. Pass data to JavaScript via data- attributes or a JSON blob with proper encoding, never through template interpolation inside script elements.

  • Assuming framework auto-escaping covers all contexts: React escapes JSX expressions rendered as text content, but it does not protect href attributes from javascript: URIs, and it cannot help if you concatenate user input into a string that gets passed to eval() or new Function(). Each context needs its own defense.

Overview

Cross-site scripting (XSS) occurs when an attacker injects malicious scripts into content that is then served to other users. XSS can lead to session hijacking, credential theft, defacement, and malware distribution. The three main categories are Reflected XSS, Stored XSS, and DOM-based XSS.

Core Concepts

Types of XSS

  • Reflected XSS: Malicious input is immediately reflected back in the HTTP response (e.g., search results echoing the query).
  • Stored XSS: Malicious input is persisted (database, file) and later rendered to other users (e.g., forum posts, comments).
  • DOM-based XSS: The vulnerability exists entirely in client-side JavaScript that processes untrusted data and writes it into the DOM.

Output Encoding Contexts

Different contexts require different encoding strategies:

ContextEncoding Required
HTML bodyHTML entity encoding
HTML attributesAttribute encoding
JavaScriptJavaScript hex encoding
URL parametersPercent/URL encoding
CSS valuesCSS hex encoding

Trusted Types API

The Trusted Types browser API prevents DOM XSS by requiring typed objects for dangerous sink assignments rather than raw strings.

Implementation Patterns

Context-Aware Output Encoding (Node.js / Express)

const he = require('he');

// HTML context — encode entities
function renderUserComment(comment) {
  return `<div class="comment">${he.encode(comment)}</div>`;
}

// Attribute context — encode and quote
function renderLink(url, label) {
  const safeUrl = encodeURI(url);
  const safeLabel = he.encode(label);
  return `<a href="${safeUrl}">${safeLabel}</a>`;
}

// Never interpolate into script blocks
// BAD:  <script>var name = "${userName}";</script>
// GOOD: pass data via data attributes or JSON with encoding
function renderDataForScript(data) {
  const encoded = JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026');
  return `<script>var config = ${encoded};</script>`;
}

React — Safe by Default, Watch the Escapes

// React auto-escapes in JSX expressions — this is safe
function UserGreeting({ name }) {
  return <h1>Hello, {name}</h1>;
}

// DANGEROUS: dangerouslySetInnerHTML bypasses React's encoding
// Only use with sanitized content
import DOMPurify from 'dompurify';

function RichContent({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
    ALLOWED_ATTR: ['href', 'target'],
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

DOMPurify for User-Generated HTML

import DOMPurify from 'dompurify';

// Basic sanitization
const dirty = '<img src=x onerror=alert(1)><b>Hello</b>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<b>Hello</b>'

// Strict config for comments
const commentConfig = {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code', 'pre', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href'],
  ALLOW_DATA_ATTR: false,
  ADD_ATTR: ['target'],
  FORBID_TAGS: ['style', 'script', 'iframe', 'form', 'object', 'embed'],
};

function sanitizeComment(rawHtml) {
  return DOMPurify.sanitize(rawHtml, commentConfig);
}

DOM-Based XSS Prevention

// BAD — direct DOM manipulation with user input
document.getElementById('output').innerHTML = location.hash.slice(1);

// GOOD — use textContent for plain text
document.getElementById('output').textContent = location.hash.slice(1);

// GOOD — use Trusted Types policy
if (window.trustedTypes && trustedTypes.createPolicy) {
  const escapePolicy = trustedTypes.createPolicy('escapePolicy', {
    createHTML: (input) => DOMPurify.sanitize(input),
  });
  element.innerHTML = escapePolicy.createHTML(userInput);
}

Server-Side Template Engines

# Jinja2 (Python/Flask) — auto-escaping enabled by default
# In app config:
app.jinja_env.autoescape = True

# Template — this is auto-escaped:
# <p>{{ user_comment }}</p>

# If you MUST render raw HTML (pre-sanitized only):
# <div>{{ sanitized_html | safe }}</div>
# Always sanitize before marking safe:
import bleach

def sanitize_for_template(raw_html):
    return bleach.clean(
        raw_html,
        tags=['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
        attributes={'a': ['href']},
        strip=True
    )

Best Practices

  1. Default to encoding: Use frameworks that auto-escape output (React, Angular, Jinja2 with autoescape). Only bypass when absolutely necessary and after sanitization.
  2. Apply context-specific encoding: HTML body, attributes, JavaScript, URLs, and CSS each need different encoding.
  3. Use Content-Security-Policy headers: CSP provides a second layer of defense even if XSS encoding is missed. See the content-security-policy skill.
  4. Sanitize on output, not just input: Data flows change over time. Encode at the point of rendering, not only at the point of storage.
  5. Avoid dangerous APIs: innerHTML, document.write(), eval(), v-html, dangerouslySetInnerHTML — avoid or gate behind sanitization.
  6. Set the HttpOnly flag on session cookies: This prevents JavaScript from reading cookies even if XSS occurs.
  7. Use a strict DOMPurify config: Allowlist tags and attributes rather than blocklisting.
  8. Enable Trusted Types: Use the Trusted Types API to enforce sanitization at the browser level.

Common Pitfalls

  • Assuming a framework auto-escapes everywhere: React escapes JSX but not dangerouslySetInnerHTML. Angular escapes templates but not bypassSecurityTrustHtml. Always verify context.
  • Encoding for the wrong context: HTML-encoding a value placed inside a <script> tag does not prevent XSS. Match the encoding to the context.
  • Sanitizing only on input: Stored data may be rendered in new contexts later. Always encode at the point of output.
  • Allowing javascript: URIs: Check that href and src attributes only accept https: or relative URLs. Sanitizers may miss protocol-level attacks.
  • Using blocklists instead of allowlists: Attackers constantly find new bypasses. Always allowlist permitted tags, attributes, and protocols.
  • Forgetting HTTP response headers: Without Content-Type: text/html; charset=utf-8, browsers may sniff content and render injected scripts.

Install this skill directly: skilldb add security-practices-skills

Get CLI access →