XSS Prevention: Cross-Site Scripting Defense Guide

Type: Software Reference Confidence: 0.95 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

XSS Attack Types

#XSS TypeVectorImpactPrevention
1Reflected XSSMalicious URL parameter reflected in page responseSession hijacking, phishingHTML-encode all reflected output; validate input on arrival
2Stored XSSMalicious payload saved to DB and rendered to other usersAccount takeover, worm propagationEncode on output; sanitize HTML if rich text needed
3DOM-based XSSClient-side JS reads attacker-controlled source and passes to dangerous sinkFull client-side compromiseAvoid dangerous sinks; use textContent; use DOMPurify
4Mutation XSS (mXSS)Browser HTML parser mutates sanitized markup into executable formBypasses sanitizersKeep DOMPurify updated; use Trusted Types API
5Blind XSSStored payload executes in admin panel or internal toolInternal system compromiseEncode all output in admin views; monitor with XSS Hunter

Output Encoding by Context

#Output ContextEncoding RuleSafe FunctionDangerous Pattern
1HTML bodyHTML entity encode & < > " 'textContent, framework auto-escapeinnerHTML = userInput
2HTML attributeHTML entity encode; always quote attributessetAttribute(name, val)<div title= + userInput + >
3JavaScript stringUnicode-escape \uXXXX or \xHH formatJSON.stringify() into script contextvar x = ' + userInput + '
4URL parameterPercent-encode %HHencodeURIComponent()href=" + userInput + "
5URL schemeAllowlist http/https onlyValidate protocol before usehref= + userInput (allows javascript:)
6CSS valueCSS hex encode \XX; allowlist valuesCSS custom properties with validated inputstyle="color: + userInput + "
7JSON in HTMLJSON.stringify + HTML entity encodeDjango |json_script, Jinja2 |tojson<script>var x = ${userInput}</script>

Decision Tree

START: Where does user input appear in your output?
├── HTML body text?
│   ├── YES → Use framework auto-escaping (React JSX, Django {{ }}, Go html/template)
│   └── NO ↓
├── HTML attribute value?
│   ├── YES → HTML-encode value AND always quote the attribute
│   └── NO ↓
├── Inside <script> tag or JavaScript string?
│   ├── YES → Use JSON.stringify() + HTML entity encode, or pass via data attributes
│   └── NO ↓
├── Inside a URL (href, src)?
│   ├── YES → Validate protocol is http/https, then encodeURIComponent() for params
│   └── NO ↓
├── Inside CSS (style attribute)?
│   ├── YES → Allowlist valid CSS values; never interpolate user input directly
│   └── NO ↓
├── User needs to submit rich HTML content?
│   ├── YES → Sanitize with DOMPurify server-side AND client-side before rendering
│   └── NO ↓
└── DEFAULT → HTML-encode all output + deploy strict CSP as defense-in-depth

Step-by-Step Guide

1. Deploy a strict Content Security Policy

CSP prevents inline script execution even if an XSS payload bypasses encoding. Use nonce-based CSP for the strongest protection. [src3]

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-{random}' 'strict-dynamic';
  style-src 'self' 'nonce-{random}';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';

Verify: Open browser DevTools > Console -- CSP violations appear as errors. Check report-uri endpoint for reports.

2. Implement server-side output encoding

Use your framework's built-in auto-escaping. Every major framework escapes by default when used correctly. [src1]

// React: JSX auto-escapes by default
function UserGreeting({ name }) {
  return <h1>Hello, {name}</h1>;
}

Verify: Inject <script>alert(1)</script> as user input -- it should render as visible text, not execute.

3. Sanitize user-supplied HTML

When you must accept HTML from users (comments, WYSIWYG editors), sanitize with DOMPurify. [src5]

import DOMPurify from 'dompurify';  // v3.x

const cleanHTML = DOMPurify.sanitize(userInput, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'],
  ALLOWED_ATTR: ['href', 'title'],
  ALLOW_DATA_ATTR: false
});

Verify: Test with payload <img src=x onerror=alert(1)> -- should be stripped.

4. Validate and encode URL schemes

Prevent javascript: and data: URL injection in href and src attributes. [src2]

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

Verify: Test with javascript:alert(1) -- should be rejected.

5. Enable Trusted Types (modern browsers)

Trusted Types prevent DOM XSS by requiring typed objects for dangerous sinks. [src7]

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

Verify: Setting innerHTML with a raw string will throw a TypeError when Trusted Types is enforced.

Code Examples

React/JSX: Safe Dynamic Content Rendering

import DOMPurify from 'dompurify';  // ^3.0.0

// SAFE: React auto-escapes expressions in JSX
function Comment({ author, text }) {
  return (
    <div className="comment">
      <strong>{author}</strong>
      <p>{text}</p>
    </div>
  );
}

// When rich HTML is required, sanitize first
function RichComment({ htmlContent }) {
  const clean = DOMPurify.sanitize(htmlContent);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Node.js/Express: Helmet CSP + Output Encoding

const express = require('express');  // ^4.18.0
const helmet = require('helmet');    // ^7.1.0
const crypto = require('crypto');
const app = express();

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [(req, res) => `'nonce-${res.locals.cspNonce}'`],
      objectSrc: ["'none'"],
      baseUri: ["'none'"]
    }
  }
}));

Python/Django: Template Auto-Escaping

# Django auto-escapes all template variables by default
from django.utils.html import escape

def profile(request, username):
    return render(request, 'profile.html', {'username': username})
    # {{ username }} is auto-escaped in the template

# DANGEROUS: {{ user_input|safe }} -- NEVER use with untrusted data
# For JSON in templates: {{ data|json_script:"user-data" }}

Go: html/template Context-Aware Escaping

package main

import (
    "html/template"  // NOT text/template
    "net/http"
)

var tmpl = template.Must(template.New("page").Parse(`
<h1>Hello, {{.Name}}</h1>
<a href="{{.URL}}">Profile</a>
`))

func handler(w http.ResponseWriter, r *http.Request) {
    data := struct{ Name, URL string }{
        Name: r.URL.Query().Get("name"),
        URL:  "/users/" + r.URL.Query().Get("id"),
    }
    tmpl.Execute(w, data)
}

Anti-Patterns

Wrong: Blacklist-based XSS filtering

// BAD -- blacklist filtering is trivially bypassed
function sanitize(input) {
  return input
    .replace(/<script>/gi, '')
    .replace(/onerror/gi, '');
}
// Bypass: <scr<script>ipt>, <img src=x OnError=alert(1)>

Correct: Context-aware output encoding

// GOOD -- encode based on output context
function escapeHtml(str) {
  const map = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#x27;'};
  return String(str).replace(/[&<>"']/g, c => map[c]);
}

Wrong: Encoding in the wrong context

<!-- BAD -- HTML encoding inside a JavaScript context does NOT prevent XSS -->
<script>
  var name = '{{html_escaped_user_input}}';
</script>

Correct: JavaScript context encoding

<!-- GOOD -- use data attributes instead -->
<div id="user" data-name="{{html_escaped_user_input}}"></div>
<script>
  var name = document.getElementById('user').dataset.name;
</script>

Wrong: CSP with unsafe-inline

# BAD -- unsafe-inline defeats CSP's XSS protection
Content-Security-Policy: script-src 'self' 'unsafe-inline'

Correct: Nonce-based CSP

# GOOD -- nonce-based CSP blocks injected inline scripts
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'

Wrong: innerHTML with user data

// BAD -- direct DOM injection of user input
document.getElementById('output').innerHTML = userInput;

Correct: textContent or DOMPurify

// GOOD -- textContent for plain text
document.getElementById('output').textContent = userInput;

// GOOD -- DOMPurify when HTML rendering is required
import DOMPurify from 'dompurify';
document.getElementById('output').innerHTML = DOMPurify.sanitize(userInput);

Common Pitfalls

Diagnostic Commands

# Check current CSP headers of a site
curl -sI https://your-site.com | grep -i content-security-policy

# Scan for XSS with OWASP ZAP (Docker)
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
  -t https://your-site.com -r report.html

# Find dangerous sinks in JavaScript codebase
grep -rn 'innerHTML\|outerHTML\|document\.write\|eval(' --include="*.js" .

# Find React dangerouslySetInnerHTML usage
grep -rn 'dangerouslySetInnerHTML' --include="*.jsx" --include="*.tsx" .

# Find Vue v-html usage
grep -rn 'v-html' --include="*.vue" .

# Validate CSP: visit https://csp-evaluator.withgoogle.com/

Version History & Compatibility

Standard/ToolVersionStatusKey Feature
CSP Level 3W3C CRCurrentstrict-dynamic, nonce-based policies
CSP Level 2W3C RecSupportedscript-src, style-src, report-uri
Trusted TypesDraftChrome 83+, Firefox behind flagDOM sink protection
DOMPurify3.xCurrentTrusted Types support, ES module
DOMPurify2.xMaintainedCommonJS, UMD bundles
Sanitizer APIDraftChrome 105+ (origin trial)Native browser HTML sanitization

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Any web app renders user input in HTMLStatic site with zero user inputNo XSS prevention needed
Building WYSIWYG editor or comment system with rich textYou can use plain text only (no HTML rendering)textContent + framework auto-escaping only
CSP is your only server-level controlYou assume WAF blocks all XSSOutput encoding at the application level
Third-party content is embedded (ads, widgets)Content from fully trusted internal sources onlyMinimal CSP + auto-escaping may suffice

Important Caveats

Related Units