Cross-Site Scripting (XSS) Prevention Cheat Sheet
Covers the three main XSS variants and concrete output-encoding, sanitization, and CSP techniques to prevent script injection in web apps.
Types of XSS
The three primary categories of cross-site scripting.
- Stored (persistent)- Malicious script saved on the server (e.g. in a comment) and served to other users
- Reflected- Script included in a request (e.g. URL param) and immediately reflected back in the response
- DOM-based- Vulnerability exists entirely in client-side JS that writes untrusted data into the DOM
Safe Output Rendering (JavaScript)
Avoiding unsafe DOM sinks and encoding output correctly.
// UNSAFE: innerHTML executes any injected <script>/event handlerselement.innerHTML = userInput;// SAFE: textContent treats input as plain text, no HTML parsingelement.textContent = userInput;// If HTML must be rendered, sanitize firstimport DOMPurify from 'dompurify';element.innerHTML = DOMPurify.sanitize(userInput);
Content Security Policy Header
Restricts which sources scripts can be loaded from as a defense-in-depth layer.
Content-Security-Policy: default-src 'self'; \ script-src 'self' https://trusted-cdn.com; \ object-src 'none'; \ base-uri 'self'
Prevention Checklist
Core practices to reduce XSS risk across an application.
- Context-aware output encoding- Encode differently for HTML body, attribute, JS, URL, and CSS contexts
- Use safe DOM APIs- Prefer textContent/setAttribute over innerHTML/document.write
- Sanitize rich HTML- Use a vetted library like DOMPurify when HTML input must be allowed
- HttpOnly cookies- Prevent JavaScript from reading session cookies via document.cookie
- Content-Security-Policy- Blocks inline scripts and untrusted origins even if injection occurs
- Framework auto-escaping- React/Vue/Angular escape output by default — avoid dangerouslySetInnerHTML/v-html unless sanitized
Trusted Types (Browser-Enforced DOM XSS Prevention)
Locks down dangerous DOM sinks so only vetted, typed values can reach them, eliminating DOM-based XSS at the platform level.
// Enforce via CSP: Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;if (window.trustedTypes && trustedTypes.createPolicy) { const policy = trustedTypes.createPolicy('dompurify', { createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true }) }); // Assigning a raw string to innerHTML now throws a TypeError // element.innerHTML = userInput; // blocked by the browser // Must go through the policy to produce a TrustedHTML object element.innerHTML = policy.createHTML(userInput);}// A default policy catches any code path that forgot to opt intrustedTypes.createPolicy('default', { createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true })});
Nonce-Based CSP with strict-dynamic
A stronger CSP pattern than allow-listing domains, immune to JSONP/CDN bypass gadgets that plague host-based script-src lists.
Content-Security-Policy: \ script-src 'nonce-r4nd0mBase64PerRequest' 'strict-dynamic' 'unsafe-inline' https:; \ object-src 'none'; \ base-uri 'self'<!-- Every legitimate <script> tag must carry the matching per-response nonce --><script nonce="r4nd0mBase64PerRequest" src="/app.js"></script><!-- Scripts dynamically inserted by a nonced script are auto-trusted under strict-dynamic; 'unsafe-inline'/https: are ignored by browsers that support strict-dynamic, kept only as a fallback -->
Escaping in Non-HTML Sinks (Tagged Templates)
Naive frameworks escape HTML but miss JS-string, URL, and CSS contexts, each of which needs its own encoding rules.
function jsStringEscape(s) { return String(s).replace(/[\\'"\n\r\u2028\u2029<>]/g, (c) => { const map = { '\\': '\\\\', "'": "\\'", '"': '\\"', '\n': '\\n', '\r': '\\r', '<': '\\u003C', '>': '\\u003E' }; return map[c] || c; });}// UNSAFE: attacker input closes the string and injects code// const html = `<script>var user = "${username}";</script>`;// SAFE: escape for the JS-string context specifically, not HTMLconst html = `<script>var user = "${jsStringEscape(username)}";</script>`;// URLs need their own encoding — never string-concat into href/srcconst safeUrl = new URL(path, 'https://example.com').toString();if (!['http:', 'https:'].includes(new URL(safeUrl).protocol)) throw new Error('bad scheme');
Mutation XSS (mXSS) Gotcha
A payload that looks sanitized before insertion can be re-parsed by the browser into an executable form after DOM round-tripping.
// Payload that DOMPurify (in older/misconfigured setups) may pass,// but which the browser's HTML parser "corrects" into an active handler// once it round-trips through innerHTML -> outerHTML -> innerHTML again.const payload = '<listing><img src=x onerror=alert(1)></listing>';// Mitigations:// 1. Sanitize as close to the sink as possible, not once at input time// 2. Never read back sanitized HTML via innerHTML/outerHTML and re-insert it// 3. Keep DOMPurify pinned to a current version (mXSS gadgets are patched)// 4. Prefer Trusted Types so the browser enforces the policy at the sink,// independent of how many times the string is round-tripped
Framework Escape Hatches That Reintroduce XSS
Modern frameworks auto-escape by default, but every one ships an explicit opt-out that bypasses it.
- React- dangerouslySetInnerHTML renders raw HTML unescaped; sanitize with DOMPurify first
- Vue- v-html directive skips escaping entirely, same risk as innerHTML
- Angular- bypassSecurityTrustHtml/bypassSecurityTrustResourceUrl disable the built-in sanitizer for that value
- Svelte- {@html expr} inserts raw markup with zero escaping
- Server templates (Jinja2/ERB)- |safe filter or raw() call disables auto-escaping for that expression
- URL/attribute bindings- href/src bound to javascript: or data: URIs execute even through 'safe' text bindings — validate schemes
- postMessage handlers- Missing origin checks let any frame inject data treated as trusted, a common DOM XSS vector frameworks don't guard
Auto-escaping in frameworks like React only protects the rendered DOM text — it won't stop XSS from unsafe href/src attribute values like javascript: URIs, so validate and allow-list URL schemes separately.