XSS Prevention: Cross-Site Scripting Defense Guide
How do I prevent Cross-Site Scripting (XSS) attacks?
TL;DR
- Bottom line: Context-aware output encoding combined with Content Security Policy (CSP) provides layered XSS defense -- no single technique is sufficient alone.
- Key tool/command:
DOMPurify.sanitize(userInput)for client-side HTML sanitization; framework auto-escaping (React JSX, Django templates, Go html/template) for server-side. - Watch out for:
dangerouslySetInnerHTML(React),v-html(Vue),[innerHTML](Angular), and rawinnerHTMLbypass framework auto-escaping and reintroduce XSS risk. - Works with: All modern web frameworks and browsers. CSP Level 3 supported in Chrome 59+, Firefox 58+, Safari 15.4+, Edge 79+.
Constraints
- NEVER trust user input in any HTML context -- always encode or sanitize before rendering
- Output encoding MUST match the insertion context (HTML body, attribute, JavaScript, URL, CSS)
- NEVER use innerHTML, dangerouslySetInnerHTML, v-html, or [innerHTML] with unsanitized user data
- CSP is defense-in-depth, NOT a replacement for output encoding
- Blacklist/regex-based XSS filtering is insufficient -- use allowlist validation and context-aware encoding
- Keep HTML sanitization libraries (DOMPurify) updated -- bypasses are discovered regularly
Quick Reference
XSS Attack Types
| # | XSS Type | Vector | Impact | Prevention |
|---|---|---|---|---|
| 1 | Reflected XSS | Malicious URL parameter reflected in page response | Session hijacking, phishing | HTML-encode all reflected output; validate input on arrival |
| 2 | Stored XSS | Malicious payload saved to DB and rendered to other users | Account takeover, worm propagation | Encode on output; sanitize HTML if rich text needed |
| 3 | DOM-based XSS | Client-side JS reads attacker-controlled source and passes to dangerous sink | Full client-side compromise | Avoid dangerous sinks; use textContent; use DOMPurify |
| 4 | Mutation XSS (mXSS) | Browser HTML parser mutates sanitized markup into executable form | Bypasses sanitizers | Keep DOMPurify updated; use Trusted Types API |
| 5 | Blind XSS | Stored payload executes in admin panel or internal tool | Internal system compromise | Encode all output in admin views; monitor with XSS Hunter |
Output Encoding by Context
| # | Output Context | Encoding Rule | Safe Function | Dangerous Pattern |
|---|---|---|---|---|
| 1 | HTML body | HTML entity encode & < > " ' | textContent, framework auto-escape | innerHTML = userInput |
| 2 | HTML attribute | HTML entity encode; always quote attributes | setAttribute(name, val) | <div title= + userInput + > |
| 3 | JavaScript string | Unicode-escape \uXXXX or \xHH format | JSON.stringify() into script context | var x = ' + userInput + ' |
| 4 | URL parameter | Percent-encode %HH | encodeURIComponent() | href=" + userInput + " |
| 5 | URL scheme | Allowlist http/https only | Validate protocol before use | href= + userInput (allows javascript:) |
| 6 | CSS value | CSS hex encode \XX; allowlist values | CSS custom properties with validated input | style="color: + userInput + " |
| 7 | JSON in HTML | JSON.stringify + HTML entity encode | Django |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 = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
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
- JSON data in script tags: Inserting JSON into
<script>blocks without escaping allows</script>injection. Fix: UseJSON.stringify()and replace</with<\/, or use Django's|json_scriptfilter. [src1] - URL scheme injection:
<a href="{{user_url}}">allowsjavascript:alert(1)even with HTML encoding. Fix: Validate URL protocol ishttp:orhttps:before use. [src2] - SVG XSS: SVGs can contain
<script>tags and event handlers. Fix: Sanitize SVGs with DOMPurify (supports SVG mode) or serve with restrictive CSP. [src5] - Template literal injection: Using
eval(`Hello ${name}`)enables code execution. Fix: Never useeval(),new Function(), orsetTimeout(string)with user data. [src6] - Markdown rendering XSS: Markdown parsers that output raw HTML can introduce XSS. Fix: Use a sanitizing Markdown renderer or pipe output through DOMPurify. [src1]
- postMessage without origin check: DOM XSS via
window.addEventListener('message', handler)without verifyingevent.origin. Fix: Always checkevent.originagainst an allowlist. [src6] - React dangerouslySetInnerHTML from API: Backend returns HTML that frontend renders without sanitization. Fix: Sanitize with DOMPurify even for "trusted" backend data. [src5]
- Double encoding: Encoding output twice shows
&lt;instead of<. Fix: Encode exactly once, at the point of output. [src2]
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/Tool | Version | Status | Key Feature |
|---|---|---|---|
| CSP Level 3 | W3C CR | Current | strict-dynamic, nonce-based policies |
| CSP Level 2 | W3C Rec | Supported | script-src, style-src, report-uri |
| Trusted Types | Draft | Chrome 83+, Firefox behind flag | DOM sink protection |
| DOMPurify | 3.x | Current | Trusted Types support, ES module |
| DOMPurify | 2.x | Maintained | CommonJS, UMD bundles |
| Sanitizer API | Draft | Chrome 105+ (origin trial) | Native browser HTML sanitization |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Any web app renders user input in HTML | Static site with zero user input | No XSS prevention needed |
| Building WYSIWYG editor or comment system with rich text | You can use plain text only (no HTML rendering) | textContent + framework auto-escaping only |
| CSP is your only server-level control | You assume WAF blocks all XSS | Output encoding at the application level |
| Third-party content is embedded (ads, widgets) | Content from fully trusted internal sources only | Minimal CSP + auto-escaping may suffice |
Important Caveats
- Framework auto-escaping only protects the contexts the framework manages -- manual DOM manipulation, eval(), and dynamic script creation are not covered
- CSP strict-dynamic trusts any script loaded by an already-trusted script, so a trusted script with a gadget (e.g., Angular, jQuery) can be exploited
- DOMPurify and all client-side sanitizers can be bypassed if the attacker controls the page before the sanitizer runs -- server-side sanitization is the primary defense
- The Sanitizer API (browser-native) is still in draft and not production-ready as of 2026
- SVG and MathML contexts have different parsing rules than HTML -- test sanitization specifically for these content types
- HTTP-only cookies prevent session theft via XSS but do not prevent other XSS impacts (defacement, keylogging, phishing)