← All posts

Defending React & Node Apps from XSS: Practical Strategies

Learn how to lock down your React/Node stack against XSS with real‑world tactics that protect both client and server in production.

React and Node are the de‑facto stack for modern web apps, but that very ubiquity means that XSS is still a daily threat. Even with JSX’s automatic escaping, developers can slip raw HTML into the DOM, and Express routes that return unfiltered data expose the same surface on the server side. The following guide walks through real‑world scenarios, explains why the problem persists, and shows production‑ready fixes that fit into a typical React/Express workflow.


TL;DR

  • Never use dangerouslySetInnerHTML without sanitizing; use DOMPurify or similar.
  • Sanitize on both client and server; server validation is non‑negotiable.
  • Helmet + CSP on Express blocks most script injection vectors.
  • Keep auto‑escaping enabled in template engines and serve assets with correct MIME types.
  • Test your policy with report‑only mode before enforcing.

Why XSS Persists in Modern SPAs

React’s virtual DOM still renders raw strings if developers bypass its escaping rules

JSX automatically escapes interpolated values, but developers often override this safety net. dangerouslySetInnerHTML is the only official way to inject raw markup, and it is misused when developers think it’s a quick fix for rich‑text comments or third‑party widgets. If the data source is not trusted, the browser will execute any <script> tags or malicious attributes that slip through.

Third‑party libraries and dynamic content injection create new attack vectors

Many apps pull Markdown, user‑generated HTML, or embed widgets from external services. If a library does not perform its own sanitization, the entire application becomes vulnerable. Even seemingly innocuous string interpolation inside a template literal can introduce XSS if the string contains <script> tags.

Node back‑ends often expose raw data through APIs or server‑side rendering, widening the surface area

Express routes that return HTML snippets or JSON objects containing raw HTML give the front‑end full control over rendering. If the API does not validate or sanitize inputs, the data can contain malicious code that the client will later inject into the DOM.


Attack Surface in React Applications

User‑generated content displayed with dangerouslySetInnerHTML or template literals

// Vulnerable
function Comment({ text }) {
  return <div dangerouslySetInnerHTML={{ __html: text }} />;
}

A user could submit <script>alert(1)</script> and the browser would execute it. The same danger exists when building a string with template literals and inserting it into JSX:

const content = `<b>${userInput}</b>`;
return <div>{content}</div>; // JSX will escape but if content contains <script> it remains raw

Embedding external HTML snippets or Markdown without sanitization

Markdown parsers often expose an html option that allows raw HTML. If you enable it and skip sanitization, any <img onerror=…> or <iframe> can execute code.

Using third‑party widgets that inject scripts or manipulate the DOM

Widgets such as live chat, comment systems, or analytics can add scripts that run in the page context. If the widget’s URL is not verified, an attacker could host a malicious script and trick the widget to load it.


Node Backend Vulnerabilities

Server‑side rendering with template engines that interpolate data without escaping

EJS, Pug, or Handlebars interpolate variables by default, but developers can override escaping with !{} or unescaped tags. An unescaped variable can inject a <script> tag into the rendered page.

<!-- Vulnerable -->
<div><%= userComment %></div>

If userComment contains <script>…</script>, the browser will run it.

Exposing raw JSON or HTML through API responses that front‑ends consume directly

An endpoint that returns res.json({ html: userComment }) gives the client a string that it may decide to render with dangerouslySetInnerHTML. Without server‑side sanitization, any malicious payload is forwarded unchanged.

Missing security headers that allow framing or MIME‑type confusion

Headers such as X‑Content‑Type‑Options: nosniff and X‑Frame‑Options: DENY prevent the browser from interpreting a response as a different MIME type or embedding the page in an iframe. Without them, attackers can trick the browser into executing malicious scripts through MIME sniffing or click‑jacking.


Preventing XSS in React

1. Let JSX escape strings; avoid dangerouslySetInnerHTML unless absolutely necessary

If you only need plain text, simply render it as {text}. React will escape <, >, &, and " automatically.

function PlainText({ text }) {
  return <p>{text}</p>;
}

2. When innerHTML is required, sanitize with libraries such as DOMPurify and keep them up‑to‑date

import DOMPurify from 'dompurify';

function SafeComment({ html }) {
  const sanitized = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

DOMPurify removes disallowed tags, attributes, and protocols (javascript:, data:). It also offers a RETURN_DOM option if you need to manipulate the sanitized node further.

3. Validate and normalize all incoming data on the client before rendering

Even with server validation, a malicious user can bypass it by crafting a custom request. Client‑side checks (e.g., ensuring a comment length, stripping disallowed characters) add a layer of defense and improve UX by rejecting bad input early.

4. Use React.StrictMode and the new automatic batching to catch unsafe patterns early

React.StrictMode does not prevent XSS, but it flags certain anti‑patterns in dev mode, such as direct DOM manipulation via ref. Keeping your codebase clean reduces the chance of accidental injection points.


Node‑Side Safeguards

1. Validate and sanitize all input at the API boundary using libraries like express‑validator

const { body, validationResult } = require('express-validator');

app.post('/comments', [
  body('text').trim().escape(), // removes tags, escapes special chars
], (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

  const { text } = req.body;
  // Store sanitized text
  res.json({ success: true });
});

escape() removes HTML tags and converts special characters, ensuring stored data is safe.

2. Apply Helmet middleware to set secure HTTP headers and enforce a Content‑Security‑Policy (CSP)

const helmet = require('helmet');

app.use(helmet());

// Example CSP that allows inline styles only via a hash or nonce
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'nonce-xyz'"], // nonce added per request
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
    },
  })
);

Helmet also sets X‑Content‑Type‑Options, X‑Frame‑Options, and Strict-Transport-Security. Using a nonce or hash for scripts lets you whitelist trusted inline scripts while blocking arbitrary <script> tags.

3. When rendering templates, always enable auto‑escaping and avoid injecting raw HTML

<!-- Safe: auto‑escaped -->
<div><%= userComment %></div>

If you must inject raw HTML, use a safe, sanitized string:

<div><%- sanitizedComment %></div> <!-- sanitizedComment already cleaned -->

4. Serve static assets with proper MIME types and enable X‑Content‑Type‑Options

app.use(express.static('public', {
  setHeaders: (res, path) => {
    res.setHeader('X-Content-Type-Options', 'nosniff');
  },
}));

This prevents the browser from interpreting a CSS file as JavaScript, for example.


Common Mistakes & Trade‑offs

Mistake Why it matters Mitigation
Over‑sanitizing Removing <b> or <i> tags breaks rich‑text editors. Configure DOMPurify to allow a whitelist of safe tags (ALLOWED_TAGS) and attributes.
Relying only on client‑side sanitization Attackers can send raw data directly to the API, bypassing the browser. Validate and sanitize on the server before storing or returning data.
Strict CSP that blocks third‑party scripts Many analytics or ad services require inline scripts. Use nonces or hashes to allow only trusted scripts, or relax the policy for specific domains.
Ignoring performance Some developers worry that sanitization slows down the app. Libraries like DOMPurify are highly optimized; the overhead is negligible compared to the cost of a breach.

Real‑World Example

Vulnerable React component

// CommentList.jsx
export default function CommentList({ comments }) {
  return (
    <div>
      {comments.map(c => (
        <div key={c.id} dangerouslySetInnerHTML={{ __html: c.body }} />
      ))}
    </div>
  );
}

Fixed React component with DOMPurify

// SafeCommentList.jsx
import DOMPurify from 'dompurify';

export default function SafeCommentList({ comments }) {
  return (
    <div>
      {comments.map(c => {
        const clean = DOMPurify.sanitize(c.body);
        return <div key={c.id} dangerouslySetInnerHTML={{ __html: clean }} />;
      })}
    </div>
  );
}

Vulnerable Express route

// routes.js
app.get('/snippet', (req, res) => {
  const snippet = req.query.snippet; // e.g., "<script>alert('xss')</script>"
  res.send(`<div>${snippet}</div>`); // unsanitized
});

Fixed route with Helmet and CSP

// routes.js
const helmet = require('helmet');
app.use(helmet());

app.get('/snippet', (req, res) => {
  const snippet = req.query.snippet || '';
  const safe = DOMPurify.sanitize(snippet);
  res.set('Content-Type', 'text/html');
  res.send(`<div>${safe}</div>`);
});

In the server example, we also set the Content-Type header and rely on Helmet to enforce CSP and other security headers. If the client still tries to inject a script, the browser will block it because the CSP forbids inline execution unless the script has a valid nonce.


Key Takeaways

  • React’s automatic escaping is a first line of defense; never bypass it with dangerouslySetInnerHTML unless you sanitize.
  • Sanitize on both client and server; server validation is mandatory because the browser can be tricked.
  • Use Helmet and a well‑crafted CSP to block most script injection vectors; test in report‑only mode first.
  • Keep libraries up‑to‑date (DOMPurify, express‑validator, Helmet) and configure them to allow only the tags/attributes your app actually needs.
  • Balance security with functionality: whitelist safe tags, use nonces for trusted inline scripts, and validate data before rendering.

By integrating these practices into your React and Node workflow, you eliminate the most common XSS vectors while preserving the rich user experience modern SPAs demand.