Cross-site scripting is still one of the easiest ways to turn a small bug into a full account takeover. I’ve seen Express apps with solid auth, rate limiting, and input validation still fall over because somebody rendered untrusted HTML into a page “just this once.”

If you build Node.js apps that render HTML, accept user content, or expose JSON to frontend code, you need a clear XSS strategy. Not a pile of random middleware. A strategy.

Here’s the version I trust in production:

  1. Escape output by default
  2. Sanitize only when you intentionally allow HTML
  3. Lock down the browser with CSP
  4. Avoid dangerous DOM patterns on the frontend
  5. Use cookie and session settings to reduce blast radius

What XSS looks like in Express

A classic reflected XSS bug usually starts with code like this:

const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  res.send(`
    <h1>Search results</h1>
    <p>You searched for: ${q}</p>
  `);
});

app.listen(3000);

If someone visits:

/search?q=<script>alert('xss')</script>

the browser executes it.

That’s the whole problem: untrusted data gets inserted into HTML without context-aware escaping.

Rule #1: Escape output, not input

A lot of developers try to “clean” input at the moment it arrives. That sounds reasonable, but it usually breaks legitimate content and misses context. Data that’s safe in HTML text is not safe in JavaScript, CSS, or URLs.

You want to preserve the original data and encode it when rendering.

Safe server-side rendering with EJS

Most templating engines can escape by default if you use them correctly.

With EJS:

const express = require('express');
const path = require('path');

const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.get('/profile', (req, res) => {
  const user = {
    name: req.query.name || 'Anonymous'
  };

  res.render('profile', { user });
});

app.listen(3000);

views/profile.ejs:

<h1>User Profile</h1>
<p>Name: <%= user.name %></p>

<%= %> escapes HTML entities, so this payload:

?name=<script>alert(1)</script>

renders as text, not executable code.

The dangerous version is:

<p>Name: <%- user.name %></p>

<%- %> outputs raw HTML. I treat raw output like handling a loaded nail gun: only use it when you absolutely mean to.

If you build HTML manually, escape explicitly

Sometimes people use res.send() with template strings for simple pages. If you do that, you need escaping:

const escapeHtml = (str) => {
  return String(str)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
};

app.get('/search', (req, res) => {
  const q = req.query.q || '';

  res.send(`
    <h1>Search results</h1>
    <p>You searched for: ${escapeHtml(q)}</p>
  `);
});

This is better than nothing, but I’d still rather use a template engine that escapes by default. Hand-rolled HTML gets messy fast.

Rule #2: Sanitization is for allowed HTML

Escaping is for plain text. Sanitization is for cases where users are allowed to submit rich HTML, like comments, CMS content, or formatted bios.

That means if users can enter:

<p>Hello <strong>world</strong></p>

you want to keep the <p> and <strong>, but strip scripts, event handlers, and dangerous URLs.

A good pattern in Express is to sanitize on write, store the sanitized version, and still render carefully.

const express = require('express');
const sanitizeHtml = require('sanitize-html');

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post('/comments', (req, res) => {
  const rawComment = req.body.comment || '';

  const safeComment = sanitizeHtml(rawComment, {
    allowedTags: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
    allowedAttributes: {
      a: ['href']
    },
    allowedSchemes: ['http', 'https', 'mailto']
  });

  // Save safeComment to the database
  res.send('Comment saved');
});

Then in your template, render the sanitized HTML intentionally:

<div class="comment"><%- comment.html %></div>

That raw output is acceptable because you sanitized it for that exact purpose.

What I would not do is sanitize everything globally and hope for the best. Over-sanitizing tends to create weird bugs, and under-sanitizing creates security bugs.

Rule #3: Don’t put untrusted data into dangerous contexts

Escaping for HTML text does not make data safe everywhere.

Dangerous: inline JavaScript

<script>
  const username = "<%= user.name %>";
</script>

This can break if the value contains quotes or script-breaking characters. A safer pattern is to serialize JSON:

<script>
  const user = <%- JSON.stringify(user) %>;
</script>

Even then, inline scripts are harder to secure with CSP. I prefer moving scripts into static files and passing data via HTML attributes or JSON script tags.

Example:

<div id="profile" data-username="<%= user.name %>"></div>
<script src="/static/profile.js" defer></script>

Then in profile.js:

const el = document.getElementById('profile');
const username = el.dataset.username;

Dangerous: HTML attributes that execute code

Never do this:

<button onclick="<%= user.input %>">Click</button>

Or this:

<img src="<%= user.avatar %>">

without validating what avatar is supposed to be. If a field is meant to be a URL, validate it as a URL and restrict protocols.

A simple URL validator:

function isSafeHttpUrl(value) {
  try {
    const url = new URL(value);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

Use it before saving or rendering user-controlled links and image sources.

Rule #4: Use CSP as backup, not as your only defense

Content Security Policy won’t fix broken rendering, but it can stop a lot of XSS payloads from executing.

For Express apps, helmet is the easiest starting point:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:'],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        frameAncestors: ["'none'"]
      }
    }
  })
);

This blocks inline scripts by default, which is exactly what you want if you’re serious about XSS prevention.

If your app still depends on inline scripts, fix that before loosening CSP. Too many teams slap 'unsafe-inline' into script-src and basically throw away the policy.

For deeper CSP rollout patterns, reporting, and nonce-based setups, see https://csp-guide.com and the official Helmet docs.

Rule #5: Be careful with frontend rendering

XSS in Node.js apps often comes from frontend code, not Express itself.

If your browser JavaScript does this:

results.innerHTML = userSuppliedHtml;

you’ve reintroduced XSS even if your server is clean.

Prefer:

results.textContent = userInput;

or DOM construction:

const p = document.createElement('p');
p.textContent = userInput;
results.appendChild(p);

innerHTML is fine only when the content has already been sanitized for HTML rendering. Same rule as server-side raw output.

Cookies and session settings matter

XSS and session theft go together. If an attacker gets script execution, they’ll often go after cookies, local storage, CSRF tokens, or privileged actions.

You should at least make session cookies HttpOnly, Secure, and SameSite.

const session = require('express-session');

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: 'lax'
    }
  })
);

HttpOnly helps prevent JavaScript from reading the cookie directly. That does not stop all XSS impact, but it reduces the easiest path to account hijacking.

Input validation still helps

Input validation is not XSS prevention by itself, but it narrows the attack surface.

If a username should be 3–30 characters of letters, numbers, and underscores, enforce that. Don’t allow arbitrary garbage and hope output encoding sorts it out later.

Example:

function isValidUsername(value) {
  return /^[a-zA-Z0-9_]{3,30}$/.test(value);
}

app.post('/signup', express.urlencoded({ extended: false }), (req, res) => {
  const { username } = req.body;

  if (!isValidUsername(username)) {
    return res.status(400).send('Invalid username');
  }

  res.send('OK');
});

Validation gives you cleaner data. Escaping and sanitization give you safer rendering. You want both.

A practical Express checklist

Here’s the stack I recommend for most server-rendered Express apps:

  • Use a template engine that escapes by default
  • Never use raw output unless content was sanitized for HTML
  • Avoid inline scripts and event handlers
  • Set a strict CSP with Helmet
  • Use textContent, not innerHTML, in frontend code
  • Validate URLs before putting them in href or src
  • Mark session cookies HttpOnly, Secure, and SameSite
  • Sanitize rich text with an allowlist, not a blocklist

And one more thing: test your app with payloads, not just code review. Put "><script>alert(1)</script> into every form field and query param you can find. If it shows up in the DOM, trace exactly how it got there.

That habit catches a lot more bugs than people expect.

For the official middleware and platform docs, check the Express, Node.js, and Helmet documentation.