Cross-Site Scripting has been a fixture on every web vulnerability list since the early 2000s. It is consistently among the most frequently reported vulnerabilities in bug bounty programs. And despite two decades of awareness, new XSS vulnerabilities appear daily in production applications.
The reason is simple: XSS prevention is context-dependent, and most developers learn only one defense (HTML encoding) when there are at least five contexts that each require different handling.
The Three Types of XSS
Reflected XSS. User input is included in the immediate response. The classic example is a search page that displays "Results for: [your search term]" without encoding. The attacker crafts a URL with a malicious payload and tricks the victim into clicking it.
Stored XSS. User input is saved in a database and later displayed to other users without encoding. Comment systems, user profiles, and forum posts are common targets. Stored XSS is more dangerous because it does not require the victim to click a crafted link.
DOM-based XSS. The vulnerability exists entirely in client-side JavaScript. The server never sees the payload. Instead, client-side code reads from a source (URL fragment, document.referrer, window.name) and writes to a sink (innerHTML, document.write, eval) without sanitization.
Why HTML Encoding Is Not Enough
The standard advice — encode output before displaying it — is correct but incomplete. The encoding must match the context where the data appears.
HTML body context:
<p>Welcome, <script>alert(1)</script></p>
HTML entity encoding works here. < becomes <, > becomes >, and the script tag is rendered as text.
HTML attribute context:
<input value="user input here">
If the input contains " onmouseover="alert(1), HTML entity encoding of quotes is needed. But if the attribute is unquoted, spaces and other characters break out without needing quotes at all. Always quote attributes.
JavaScript context:
<script>var name = "USER_INPUT";</script>
HTML encoding does not help here. A payload of "; alert(1); // breaks out of the string. JavaScript string escaping is needed — but even that is tricky with Unicode escapes and template literals.
URL context:
<a href="USER_INPUT">Click</a>
A payload of javascript:alert(1) executes when clicked. URL validation (ensure the scheme is http or https) is needed in addition to encoding.
CSS context:
<div style="background: USER_INPUT">
CSS supports expressions and url() values that can execute JavaScript in older browsers. CSS contexts need their own encoding.
The takeaway: you must know which context the data will appear in and apply the correct encoding for that context. Using HTML encoding everywhere creates a false sense of security.
Framework Auto-Encoding
Modern frameworks provide automatic output encoding that covers the most common cases.
React escapes all values embedded in JSX by default. {userInput} in JSX is safe because React escapes HTML entities. The danger is dangerouslySetInnerHTML, which bypasses encoding entirely.
Angular sanitizes values bound in templates by default. It handles HTML, style, URL, and resource URL contexts. The bypass is bypassSecurityTrustHtml() and similar methods.
Vue escapes double-brace interpolation ({{ }}). The danger is v-html, which renders raw HTML.
Django/Jinja2 auto-escapes template variables. The bypass is the |safe filter or {% autoescape false %}.
The pattern is consistent: frameworks default to safe output, but every framework provides an escape hatch. XSS happens when developers use the escape hatch without proper manual sanitization.
DOM-Based XSS
DOM XSS deserves special attention because server-side defenses do not help. The payload never touches the server.
Dangerous sinks:
element.innerHTML = userInput— renders HTML, including script tagsdocument.write(userInput)— writes raw HTML to the documenteval(userInput)— executes arbitrary JavaScriptsetTimeout(userInput, 1000)— executes string argument as codeelement.setAttribute('onclick', userInput)— creates event handlerslocation.href = userInput— navigates to potentially malicious URLs
Dangerous sources:
document.location(and its properties)document.referrerwindow.namepostMessagedata- URL hash fragments
Prevention: use textContent instead of innerHTML. Use addEventListener instead of inline event handlers. Validate URLs before navigation. Never pass user input to eval or setTimeout with a string argument.
Content Security Policy
CSP is a defense-in-depth layer that reduces the impact of XSS even when encoding fails.
A strict CSP:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
This prevents inline scripts, inline styles, and loading resources from external origins. Even if an attacker injects a <script> tag, the browser refuses to execute it because inline scripts are not allowed.
The challenge is deployment. Many applications rely on inline scripts and third-party resources. Migrating to a strict CSP requires refactoring inline scripts to external files and maintaining a nonce-based or hash-based allow list.
Start with Content-Security-Policy-Report-Only to identify violations without breaking functionality. Gradually tighten the policy based on the reports.
Sanitization Libraries
When you need to accept HTML input (rich text editors, markdown rendering), encoding destroys the content. You need sanitization — removing dangerous elements while preserving safe ones.
DOMPurify is the standard for client-side HTML sanitization. It parses HTML, walks the DOM tree, and removes elements and attributes that could execute scripts. It handles mutation XSS, namespace confusion, and other advanced bypass techniques.
Server-side options include Bleach (Python), HtmlSanitizer (.NET), and sanitize-html (Node.js). These parse HTML and whitelist allowed elements and attributes.
Never write your own HTML sanitizer. Regular expressions cannot safely parse HTML, and blacklist approaches miss edge cases. Use a well-maintained library.
HTTP-Only and Secure Cookies
If XSS does occur, limiting what the attacker can steal reduces the impact.
- HttpOnly cookies prevent JavaScript from accessing session cookies. Even with XSS, the attacker cannot steal the session token via
document.cookie. - Secure cookies ensure cookies are only sent over HTTPS.
- SameSite cookies prevent CSRF attacks that XSS could facilitate.
These do not prevent XSS. They limit its impact. An attacker with XSS can still perform actions as the user, modify the page, redirect to phishing sites, and access any data the page can access. But they cannot directly steal the session for use from their own browser.
Testing for XSS
Manual testing: Inject payloads into every input and observe where they appear in responses. Use context-specific payloads — <script>alert(1)</script> for HTML body, " onfocus="alert(1) for attributes, javascript:alert(1) for URL contexts.
DAST: Automated scanners fire XSS payloads and check if they appear unencoded in responses. Modern scanners handle reflected and some stored XSS. DOM-based XSS requires browser-based scanning.
SAST: Static analysis tracks taint from user input to output sinks. Effective for reflected and stored XSS. Less effective for DOM-based XSS in complex JavaScript.
Browser developer tools: The Elements panel shows exactly how user input is rendered. Check whether injected HTML is parsed as HTML or displayed as text.
How Safeguard.sh Helps
Safeguard.sh monitors the third-party libraries that handle output encoding, HTML sanitization, and CSP in your applications. When a vulnerability is disclosed in DOMPurify, your template engine, or your framework's auto-encoding mechanism, Safeguard.sh alerts you immediately and identifies every application in your portfolio that uses the affected component. This supply chain visibility ensures that a vulnerability in a foundational security library does not silently undermine your XSS defenses across multiple applications.