Cross-Site Scripting (XSS)
XSS is the injection of attacker-controlled scripts into a page that runs in other users' browsers. A successful XSS payload can steal session cookies, impersonate the victim, redirect to phishing pages, or silently exfiltrate data. It is consistently in the OWASP Top 10 and disproportionately common because every place you render user-supplied content is a potential vector. This page covers the three XSS types, the defences at each layer (output encoding, CSP, HttpOnly cookies), and the server-side role in preventing stored XSS.
The three types
Type | How it works | Example |
|---|---|---|
Stored (persistent) | Malicious input saved to DB, rendered to all viewers | Forum post with |
Reflected | Payload in the URL, server echoes it in the response |
|
DOM-based | Client-side JS reads URL/storage and writes to DOM unsafely |
|
Defence 1 — output encoding
// In server-rendered templates, use the engine's auto-escaping output tag:
// EJS:
<%= user.bio %> // ✅ HTML-escaped: '<script>' becomes '<script>'
<%- user.bio %> // ❌ raw — only for already-sanitized HTML
// Pug:
= user.bio // ✅ auto-escaped
!= user.bio // ❌ raw
// Handlebars:
{{ user.bio }} // ✅ auto-escaped
{{{ user.bio }}} // ❌ raw
// React / JSX — safe by default:
<p>{user.bio}</p> // ✅ auto-escaped
<p dangerouslySetInnerHTML={{ __html: user.bio }} /> // ❌ rawDefence 2 — Content Security Policy
// A strict CSP prevents inline scripts from running even if injected:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // no 'unsafe-inline' → injected <script> tags do nothing
// Use nonces for legitimate inline scripts:
// scriptSrc: ["'self'", `'nonce-${generateNonce()}'`],
},
},
}))Defence 3 — HttpOnly cookies
// An HttpOnly cookie cannot be read by document.cookie — blocks cookie theft via XSS:
res.cookie('session', token, {
httpOnly: true, // ← JavaScript cannot read this cookie
secure: true,
sameSite: 'lax',
})Stored XSS — sanitize before storage
import DOMPurify from 'isomorphic-dompurify'
// User submits a rich-text blog post:
app.post('/posts', authenticate, asyncHandler(async (req, res) => {
const { title, body } = req.body
// Sanitize the HTML body before storing:
const safeBody = DOMPurify.sanitize(body, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'blockquote', 'code'],
ALLOWED_ATTR: ['href'],
})
await db.posts.create({ title, body: safeBody, authorId: req.user.id })
res.status(201).json({ ok: true })
}))DOM-based XSS — the client-side version
// ❌ Reads from location.hash or URLSearchParams and writes to DOM without encoding:
document.getElementById('message').innerHTML = location.hash.slice(1)
// Attacker crafts: https://yourapp.com/page#<img src=x onerror=stealCookies()>
// ✅ Use textContent for plain text:
document.getElementById('message').textContent = location.hash.slice(1)
// ✅ Or DOMPurify if HTML is genuinely needed:
document.getElementById('message').innerHTML = DOMPurify.sanitize(location.hash.slice(1))XSS prevention checklist
Auto-escaping output in all templates — never use raw modes with untrusted input.
Sanitize rich HTML before storage with DOMPurify or sanitize-html.
CSP without
unsafe-inlineas a second layer.HttpOnlysession cookies to limit cookie theft if a payload runs.Avoid
innerHTML,document.writein client-side code; usetextContent.Review code for
dangerouslySetInnerHTML,v-html,<%- %>in PRs.