Preventing Cross-Site Scripting (XSS) in Node.js and Express
> - Core Concept: Use helmet middleware for security headers, sanitize HTML inputs with DOMPurify on both client and server, and avoid rendering unescaped user input inside template engines.

Node.js and Express backends frequently serve HTML templates via engines like EJS, Pug, or Handlebars, render server-side Markdown content, or serialize dynamic JSON payloads to single-page applications. Without systematic sanitization and defensive HTTP headers, an application can expose its users to severe Cross-Site Scripting (XSS) attacks.
To grasp how attackers manipulate DOM execution and bypass client controls, review our comprehensive primer on Cross-Site Scripting (XSS) mechanics and threat models. If you are hardening database interactions across the same service, review our guide on preventing SQL injection in Node.js and Express.
In this tutorial, we implement multi-layer defenses against XSS in Express: HTTP security headers with Helmet, robust Content Security Policy (CSP) rules, safe template rendering, and server-side HTML sanitization.
Hardening HTTP response headers with Helmet
The first line of defense in Express is Helmet, a middleware package that sets crucial HTTP security headers automatically:
// SECURE: Integrating Helmet with strict Content Security Policy
import express from 'express';
import helmet from 'helmet';
import crypto from 'node:crypto';
const app = express();
// Generate a cryptographically random nonce per request
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
// Configure Helmet with dynamic nonce-based CSP
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://images.unsplash.com"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"] // Blocks clickjacking
}
},
crossOriginEmbedderPolicy: false
})(req, res, next);
});
With this configuration:
- Untrusted inline scripts injected by an attacker will fail to execute because they lack the per-request
nonce. - External scripts from unauthorized domains are blocked by the browser automatically.
For additional cross-origin perimeter rules, review our tutorial on CORS misconfiguration prevention.
Safe server-side template rendering
When using server-side template engines in Express, always rely on automatic escaping and avoid unescaped output tags:
| Template Engine | Safe Auto-Escaped Syntax | Dangerous Raw Output Syntax (Avoid) |
|---|---|---|
| EJS | <%= userInput %> |
<%- userInput %> |
| Handlebars | {{ userInput }} |
{{{ userInput }}} |
| Pug | p= userInput or #{userInput} |
p!= userInput or !{userInput} |
// DANGEROUS: Rendering unescaped HTML in EJS
// In profile.ejs: <div class="bio"><%- user.bio %></div> <!-- VULNERABLE TO STORED XSS -->
// SECURE: Use standard escaped tags
// In profile.ejs: <div class="bio"><%= user.bio %></div>
Sanitizing user-authored rich text on the server
If your platform allows users to submit Markdown or formatted HTML (such as blog posts, comments, or documentation), you must sanitize the markup before saving or rendering.
Use DOMPurify paired with jsdom for reliable Node.js server-side sanitization:
// SECURE: Server-side HTML sanitization with DOMPurify
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
export function renderUserContent(rawMarkdown) {
// 1. Convert markdown to raw HTML
const rawHtml = marked.parse(rawMarkdown);
// 2. Sanitize HTML using DOMPurify with strict tag and attribute whitelists
const cleanHtml = purify.sanitize(rawHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel']
});
return cleanHtml;
}
DOMPurify strips out malicious tags (<script>, <iframe>, <object>) and removes dangerous event handlers (onload, onerror, onclick).
Protecting session cookies from theft
If an attacker manages to bypass output filters, your final safeguard is preventing cookie access:
// SECURE: Cookie flags preventing client-side script access
import session from 'express-session';
app.use(session({
name: '__Secure-sess',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevents document.cookie access in JavaScript
secure: process.env.NODE_ENV === 'production', // Enforces HTTPS
sameSite: 'lax', // Protects against cross-site request forgery
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
The httpOnly: true flag ensures that even if an attacker executes script payloads in the browser, they cannot read the session cookie via document.cookie.
Common XSS pitfalls in Express applications
| Anti-Pattern | Security Risk | Recommended Mitigation |
|---|---|---|
Using res.send(userInput) |
Express infers text/html and renders unescaped tags |
Return JSON via res.json() or encode before sending |
| Unescaped JSON in Server HTML | Embedding <script>window.__PRELOADED_STATE__ = ${JSON.stringify(data)}</script> allows script tag breakout |
Use serialize-javascript instead of raw JSON.stringify |
| Custom Regex Sanitizers | Attackers easily bypass regex with obfuscated payloads | Use mature, audited libraries like DOMPurify exclusively |
| Missing Helmet Headers | Browser lacks CSP and security protections | Apply app.use(helmet()) at the top of the Express middleware chain |
Express XSS defense checklist
Ensure these controls are verified prior to production deployment:
-
helmetmiddleware is mounted with a custom Content Security Policy and nonce verification. - Template engines use escaped syntax exclusively; unescaped flags (
<%-,{{{) are audited and restricted. - All user-submitted HTML/Markdown is sanitized server-side with DOMPurify.
- Session and authentication cookies have
httpOnly: true,secure: true, andsameSiteconfigured. - State preloading scripts use safe serialization (
serialize-javascript) to prevent</script>breakouts.
Bottom line
Cross-Site Scripting in Node.js and Express is fully preventable. By combining Helmet's Content Security Policy headers, strict template output escaping, server-side DOMPurify sanitization, and HttpOnly cookie flags, engineering teams create a robust defense-in-depth architecture that completely shields users from XSS exploits.
Sources: Express Security Best Practices Documentation, Helmet.js Content Security Policy Guide, DOMPurify Security Specifications, OWASP Top 10 (evaluated November 2026).



