Cross-Site Scripting (XSS) Mitigation
Cross-site scripting (XSS) lets attackers inject executable code into pages viewed by other users — enabling session hijacking, credential theft, keylogging, and full account takeover without requiring any server compromise. It remains a top OWASP vulnerability class because the attack surface spans every rendering context a web application touches. This guide is part of Vulnerability Patterns & Web Mitigation Strategies and covers the full mitigation stack: attack classification, context-aware encoding, framework hardening, Content Security Policy, and CI validation. For complementary server-side attack prevention, see injection attack prevention and CSRF defense.
Threat Anatomy
XSS exploits the browser’s inability to distinguish between legitimate page content and attacker-injected markup. The attacker’s payload reaches a DOM sink — an API or property that interprets strings as executable HTML or JavaScript — and runs in the victim’s security origin, granting full access to cookies, localStorage, page DOM, and outbound network requests.
MITRE ATT&CK classifies XSS under T1059.007 (JavaScript execution) and T1185 (browser session hijacking). The three canonical execution paths each demand different controls:
| Vector | Execution Path | Primary Sink | Persistence |
|---|---|---|---|
| Stored | Payload persisted in database or file; rendered on subsequent requests | Comment systems, user profiles, rich-text editors | Permanent until removed |
| Reflected | Payload embedded in HTTP request, immediately mirrored in response | Search parameters, URL query strings, error messages | Per-request; requires social engineering |
| DOM-Based | Payload processed entirely client-side through JavaScript execution | location.hash, document.URL, innerHTML, eval() |
Per-session; no server log trail |
A fourth category — mutation XSS (mXSS) — deserves attention: browsers can mutate sanitized HTML during serialization, re-introducing executable markup. The DOM-based vulnerability sanitization guide covers mXSS defence in depth.
The data-flow diagram below illustrates how each vector reaches execution:
Prerequisites & Scope
Before applying the controls in this guide, ensure the following are in place:
- Dependency inventory: Know every templating engine, UI library, and rich-text editor in use. Controls differ across React, Vue, Angular, server-side Jinja2/Handlebars, and raw DOM APIs.
- Output encoding library chosen: OWASP Java Encoder,
he(Node.js), or an equivalent battle-tested library must be available. Do not write encoding logic from scratch. - CSP reporting endpoint: A
/csp-reportroute or third-party reporting service (e.g.report-uri.com) must exist before deploying enforcement-mode CSP, so violations are captured rather than silently broken. - SAST tooling enabled: ESLint with security plugins (
eslint-plugin-security,react/no-danger) should be running in CI to catch dangerous API usage before code merges. - Threat model baseline: Apply the STRIDE framework to identify every user-controlled input that flows to a rendering context. Trust boundaries from trust boundary mapping determine which inputs cross into the DOM.
Mitigation Architecture
Defence against XSS is layered: no single control is sufficient. The hardened architecture combines output encoding (primary), framework auto-escaping (secondary), sanitization for rich text (tertiary), and CSP (backstop).
| Defence Layer | Mechanism | Stops | Fails Against |
|---|---|---|---|
| Context-aware output encoding | Encode characters at each DOM sink context | Stored & reflected XSS | DOM-based sinks fed by encoded server data |
| Framework auto-escaping | React JSX, Vue {{ }}, Angular interpolation |
Template injection | dangerouslySetInnerHTML, v-html, [innerHTML] bypass |
| Allowlist sanitization (DOMPurify) | Strip disallowed tags/attributes before DOM insertion | Rich-text stored XSS, mXSS | Misconfigured allowlists, DOMPurify version bugs |
| Content Security Policy (nonce/hash) | Browser refuses un-nonce’d inline scripts | Inline script execution backstop | Data exfil via img src, stylesheet injection |
HttpOnly + SameSite cookies |
Blocks JS cookie access; restricts cross-site sends | Session cookie theft via XSS | DOM-based data theft not targeting cookies |
The vulnerable pattern is a single server-side innerHTML assignment with unencoded user data. The hardened pattern replaces that with textContent for plain text or DOMPurify for rich text, backs it with a strict CSP, and validates the result in CI.
Step-by-Step Implementation
Step 1 — Map Untrusted Data Flows and DOM Sinks (OWASP ASVS V5.1.1)
Before writing a line of mitigation code, enumerate every path from external input to DOM output. Treat the following as high-priority sinks: innerHTML, outerHTML, document.write, document.writeln, eval, setTimeout(string), setInterval(string), new Function(string), location.href assignment, src/href attribute injection.
// Tool: grep for dangerous sink usage across the codebase
// Run in CI to detect new introductions
const DANGEROUS_SINKS = [
/innerHTML\s*=/,
/outerHTML\s*=/,
/document\.write\(/,
/\beval\(/,
/dangerouslySetInnerHTML/,
/v-html\s*=/,
/\[innerHTML\]/,
];
// In your linting or pre-commit hook:
// npx eslint --rule '{"no-restricted-syntax": ["error", ...]}' src/
Step 2 — Apply Context-Aware Output Encoding (OWASP ASVS V5.2.1)
Encoding must match the DOM context the data enters. Applying HTML encoding to a JavaScript string context is incorrect and can still be exploited.
// Context-aware encoding utility
// Each encoding function targets one specific DOM context
const ENTITY_MAP: Record<string, string> = {
'&': '&', '<': '<', '>': '>',
'"': '"', "'": ''', '=': '=', ' ': ' ',
};
/** HTML body context: encode the five unsafe characters */
export function encodeHtml(data: string): string {
return data.replace(/[&<>"']/g, c => ENTITY_MAP[c] ?? `&#x${c.charCodeAt(0).toString(16)};`);
}
/** HTML attribute context: also encode equals and whitespace */
export function encodeAttribute(data: string): string {
return data.replace(/[&<>"'=\s]/g, c => ENTITY_MAP[c] ?? `&#x${c.charCodeAt(0).toString(16)};`);
}
/** JavaScript string context: JSON-encode to avoid injection into script blocks */
export function encodeForScript(data: unknown): string {
// JSON.stringify escapes backslash, quote, and control chars safely
return JSON.stringify(String(data));
}
/** URL query/path context: percent-encode all non-safe characters */
export function encodeUrl(data: string): string {
return encodeURIComponent(data);
}
// Usage:
// <div data-user="${encodeAttribute(userInput)}">
// <p>${encodeHtml(userInput)}</p>
// const user = ${encodeForScript(userInput)}; // inside a <script> block
Step 3 — Harden Framework Bindings (OWASP ASVS V5.2.4)
React, Vue, and Angular escape interpolated values by default, but each exposes an escape hatch that developers sometimes use without understanding the risk. For framework-specific patterns with working examples, see escaping user input in React and Vue components.
// React: never use dangerouslySetInnerHTML without sanitization
// UNSAFE:
const BadComponent = ({ userHtml }: { userHtml: string }) => (
<div dangerouslySetInnerHTML={{ __html: userHtml }} />
);
// SAFE: sanitize before setting, or use textContent via children
import DOMPurify from 'dompurify';
const SafeRichText = ({ userHtml }: { userHtml: string }) => {
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'br', 'blockquote'],
ALLOWED_ATTR: ['href', 'title', 'rel'],
FORBID_ATTR: ['style', 'class'],
ALLOW_DATA_ATTR: false,
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
};
// For plain text, always prefer:
const SafePlainText = ({ text }: { text: string }) => <span>{text}</span>;
// React encodes text children automatically — no dangerouslySetInnerHTML needed
// Vue: v-html requires the same DOMPurify treatment
// UNSAFE: <div v-html="userContent" />
// SAFE: sanitize in a computed property or method
import { computed } from 'vue';
import DOMPurify from 'dompurify';
const safeHtml = computed(() =>
DOMPurify.sanitize(props.userContent, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'li', 'br'],
ALLOWED_ATTR: ['href', 'rel'],
ALLOW_DATA_ATTR: false,
})
);
// Template: <div v-html="safeHtml" />
Step 4 — Eliminate Unsafe DOM APIs (OWASP ASVS V5.2.5, NIST SI-10)
Replace every dynamic string sink with a safe alternative. This step must be enforced via ESLint rules rather than code review alone, because violations are easy to introduce accidentally.
// Replace dangerous patterns with safe DOM APIs
// UNSAFE: document.write and eval
document.write('<div>' + userInput + '</div>');
eval('console.log(' + userInput + ')');
// SAFE: createElement and textContent
const div = document.createElement('div');
div.textContent = userInput; // textContent never executes markup
document.body.appendChild(div);
// UNSAFE: innerHTML for plain text
element.innerHTML = userInput;
// SAFE: textContent
element.textContent = userInput;
// UNSAFE: building URLs with string concatenation
window.location.href = '/redirect?next=' + userInput;
// SAFE: URL API with validation
const url = new URL('/redirect', window.location.origin);
url.searchParams.set('next', userInput);
// Validate destination is same-origin before navigating
if (url.origin === window.location.origin) {
window.location.href = url.toString();
}
Step 5 — Deploy a Strict Content Security Policy (OWASP ASVS V14.4.3, NIST SI-10)
CSP is a browser-enforced backstop, not a replacement for encoding. Start with report-only mode to collect violations for at least one full request cycle before switching to enforcement.
# Nginx: nonce-based CSP — generate $script_nonce per request via application middleware
# and pass it to Nginx as a header or variable
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-$script_nonce';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://cdn.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
report-uri /csp-report;
" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
// Express middleware: generate a cryptographic nonce per request
import { randomBytes } from 'crypto';
export function cspNonceMiddleware(req: Request, res: Response, next: NextFunction) {
const nonce = randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; frame-ancestors 'none'; base-uri 'self';`
);
next();
}
// In template (EJS/Handlebars): <script nonce="<%= cspNonce %>">...</script>
Edge Cases & Bypass Patterns
Even with encoding and CSP in place, the following scenarios can undermine mitigations:
1. Angular expression injection via template compiler
Angular’s template syntax ({{ }}) is safe when used in compiled templates, but DomSanitizer.bypassSecurityTrustHtml() disables all sanitization. Any use of this API requires manual review and a DOMPurify pre-sanitization pass.
2. CSP bypass via JSONP endpoints
If script-src allowlists a domain that serves JSONP (e.g. Google APIs with ?callback= support), attackers can inject arbitrary JavaScript through the callback parameter. Audit all allowlisted script origins for JSONP support before deploying CSP.
3. Prototype pollution enabling XSS
Libraries that perform deep object merges without prototype protection can have their sanitizer configuration poisoned. Pin DOMPurify to a specific version and audit merge utilities (lodash.merge, deepmerge) for prototype pollution vectors. See injection attack prevention for related object injection patterns.
4. Mutation XSS (mXSS) via serialization round-trips
Some browsers mutate sanitized HTML during innerHTML serialize → parse cycles, re-introducing executable markup. DOMPurify 3.x includes mXSS protection via FORCE_BODY and WHOLE_DOCUMENT flags. Always use the current major version and enable these options when processing rich text.
5. Open redirect chaining to reflected XSS
A javascript: URI passed to an unvalidated redirect parameter produces reflected XSS. Enforce a strict allowlist of redirect destinations and validate with the URL API to reject non-https: schemes.
For DOM-specific bypass patterns in depth, see DOM-based vulnerability sanitization.
Automated Testing & CI Validation
Manual code review is insufficient at scale. Encode XSS verification in automated tests that run on every pull request.
// Unit test: context-aware encoding
import { encodeHtml, encodeAttribute, encodeUrl } from './encoding';
describe('context-aware encoding', () => {
test('HTML body: escapes script tag characters', () => {
expect(encodeHtml('<script>alert(1)</script>')).toBe(
'<script>alert(1)</script>'
);
});
test('attribute: encodes equals and whitespace', () => {
expect(encodeAttribute('a" onmouseover="evil')).not.toContain('"');
});
test('URL: percent-encodes special characters', () => {
expect(encodeUrl('"><script>alert(1)</script>')).not.toContain('<');
});
});
// Integration test: DOMPurify allowlist
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('');
const purify = DOMPurify(window as unknown as Window & typeof globalThis);
describe('DOMPurify allowlist', () => {
const config = {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'rel'],
ALLOW_DATA_ATTR: false,
};
test('strips script tags', () => {
const dirty = '<p>Hello</p><script>alert(1)</script>';
expect(purify.sanitize(dirty, config)).not.toContain('<script>');
});
test('strips onerror attributes', () => {
const dirty = '<img src=x onerror="alert(1)">';
expect(purify.sanitize(dirty, config)).not.toContain('onerror');
});
test('strips javascript: href', () => {
const dirty = '<a href="javascript:alert(1)">click</a>';
expect(purify.sanitize(dirty, config)).not.toContain('javascript:');
});
});
# CI gate: GitHub Actions — runs encoding tests and SAST scan on every PR
name: XSS Security Gates
on: [pull_request]
jobs:
xss-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run encoding unit tests
run: npm test -- --testPathPattern="encoding|sanitization"
- name: ESLint security scan (no-danger, no-eval)
run: |
npx eslint src/ \
--rule 'react/no-danger: error' \
--rule 'no-eval: error' \
--rule 'no-implied-eval: error'
- name: Check for raw innerHTML usage
run: |
if grep -rn '\.innerHTML\s*=' src/ --include='*.ts' --include='*.tsx' --include='*.js'; then
echo "ERROR: Raw innerHTML assignment found. Use textContent or DOMPurify."
exit 1
fi
- name: Verify CSP header present in server config
run: grep -q "Content-Security-Policy" nginx.conf || grep -q "cspNonceMiddleware" src/
Compliance Mapping
| Framework | Control | Requirement | Satisfied By |
|---|---|---|---|
| OWASP ASVS | V5.1.1 | Input validation and data flow documentation | Sink enumeration in Step 1 |
| OWASP ASVS | V5.2.1 | Output encoding for HTML context | encodeHtml() utility + framework auto-escaping |
| OWASP ASVS | V5.2.4 | No dangerous DOM APIs without sanitization | ESLint rule + DOMPurify in Step 3–4 |
| OWASP ASVS | V14.4.3 | Content Security Policy header | Nonce-based CSP in Step 5 |
| SOC 2 CC6.1 | Common Criteria | Logical access controls for transmitted data | CSP + HttpOnly/SameSite cookie flags |
| SOC 2 CC7.1 | Common Criteria | Vulnerability detection and monitoring | SAST gate + CSP violation reporting |
| NIST SP 800-53 | SI-10 | Information input validation | Context-aware encoding + allowlist sanitization |
| NIST SP 800-53 | SC-28 | Protection of information at rest/transit | HttpOnly cookies; Secure flag; HTTPS-only CSP origins |
| ISO 27001 | A.14.2.5 | Secure system engineering principles | SDLC integration; encoding as a mandatory code-review checklist item |
| PCI-DSS | 6.2.4 | Prevent common software attacks | Output encoding + CSP; documented in security policy |
Common Pitfalls Checklist
Frequently Asked Questions
Is input validation sufficient to prevent XSS?
No. Validation restricts acceptable input formats but does not neutralize execution context. A string that passes format validation can still contain characters that are executable in an HTML or JavaScript context. Output encoding, applied at the specific sink the data reaches, is the primary control; validation is a complementary layer.
How does CSP handle stored versus reflected XSS differently?
It does not distinguish between them — CSP blocks unauthorized script execution regardless of injection vector. A nonce-based policy prevents any inline script from running unless it carries the current request’s nonce value, which the attacker cannot know. This backstop works for both stored payloads (injected into a database and later rendered) and reflected payloads (mirrored immediately in the HTTP response).
Are modern frontend frameworks immune to XSS?
No. React, Vue, and Angular auto-escape interpolated template values, which eliminates the most common XSS vector. However, each framework exposes explicit escape hatches — dangerouslySetInnerHTML (React), v-html (Vue), [innerHTML] (Angular), and bypassSecurityTrustHtml (Angular DomSanitizer) — that disable this protection entirely. Applications that use these APIs without DOMPurify sanitization are vulnerable regardless of framework version.
Which OWASP ASVS controls specifically govern XSS prevention?
The primary section is ASVS V5.2 (Output Encoding and Injection Prevention). V5.1 covers input validation as a complementary control, and V14.4 mandates security response headers including CSP. For compliance documentation, map each encoding utility and CSP header to its corresponding control ID and include a test evidence artifact (test run output, scanner report) in your audit package.
Related
- Escaping User Input in React and Vue Components — framework-specific binding patterns and component-level hardening
- DOM-Based Vulnerability Sanitization — client-side sink analysis, mXSS, and trusted types
- Cross-Site Request Forgery (CSRF) Defense — token lifecycle management and the XSS/CSRF intersection
- Injection Attack Prevention — parameterized queries and server-side injection prevention
- Vulnerability Patterns & Web Mitigation Strategies — parent section covering the full web vulnerability mitigation stack