XSS in React, Vue and Server Components

By Davy Rogers

Frameworks auto-encode by default. Here's where XSS still slips through.

Modern frameworks made cross-site scripting much rarer. React, Vue, Angular, and Next.js all encode output by default, so the everyday case of dropping a variable into the page is safe without you thinking about it. That's genuinely good news. It's also why the XSS that remains tends to hide in the handful of places where you, deliberately or not, switch the encoding off.

So this lesson is really a tour of the escape hatches.

Raw HTML: dangerouslySetInnerHTML and v-html

The names are a warning. These props hand the browser raw HTML with no encoding.

<div dangerouslySetInnerHTML={{ __html: userContent }} />
<div v-html="userContent"></div>

If userContent came from a user, an attacker injects a script. When you genuinely need to render HTML, sanitise it first:

import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userContent);

URLs in href and src

Frameworks encode text, but they don't vet URL schemes.

<a href={userUrl}>Click here</a>

If userUrl is javascript:alert(document.cookie), the encoding doesn't help, because the value is valid as an attribute. It's the scheme that's hostile. Check it yourself:

function isSafeUrl(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ["http:", "https:", "mailto:"].includes(parsed.protocol);
  } catch {
    return false;
  }
}

Server Components and SSR

Server-rendered output isn't automatically safe just because it came from your server. If server data includes unsanitised HTML and the client renders it with dangerouslySetInnerHTML, you have XSS regardless of where the string originated. Sanitise before rendering raw HTML, whatever the source.

The other SSR trap is building HTML by hand in an API route, which sidesteps React's encoding completely:

// Next.js API route - bypasses React encoding
export default function handler(req, res) {
  const name = req.query.name;
  res.send(`<html><body>Hello ${name}</body></html>`);
}

Don't assemble HTML out of strings. Let the framework render it.

postMessage

A message listener that writes straight to innerHTML, with no origin check, is a classic DOM XSS:

// Vulnerable
window.addEventListener("message", (event) => {
  document.getElementById("output").innerHTML = event.data;
});

// Fixed
window.addEventListener("message", (event) => {
  if (event.origin !== "https://trusted.example.com") return;
  document.getElementById("output").textContent = event.data;
});

Verify the origin, and prefer textContent over innerHTML whenever you're inserting text rather than markup.

Markdown rendering

Most markdown renderers pass raw HTML straight through by default, which means a comment like this is live:

This is a comment: <img src=x onerror=alert(1)>

Run the rendered output through a sanitiser before it reaches the page:

import { marked } from "marked";
import DOMPurify from "dompurify";
const html = DOMPurify.sanitize(marked.parse(userMarkdown));

Third-party components

Anything that accepts HTML, rich text, or markdown deserves a look, because a badly written component might reach for innerHTML internally where you can't see it. Treat that as part of your code, because at runtime it is.

Content Security Policy as a backstop

Everything above is about not introducing XSS. CSP is about surviving it when you do. Even if a payload makes it onto the page, a tight policy can stop it executing:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Frameworks that need inline script for hydration can use a nonce instead of opening script-src back up:

Content-Security-Policy: script-src 'self' 'nonce-abc123'

The short version

Auto-encoding handles the common case. Your job is the exceptions: never feed unsanitised data to dangerouslySetInnerHTML, v-html, or innerHTML; validate URL schemes before they hit href or src; sanitise markdown output; check postMessage origins; and deploy a CSP so a mistake doesn't become a breach.

Labs