FREE1 free audit every month. No card, every finding included.

GUIDESEPISODES

EP 22 · SCOREAster × Claude

XSS in React: stop a user’s name running code

You asked your AI for names in bold and colours, so it showed them as raw HTML. One stranger typed code as his name, and now it runs for everyone who opens the page. Here is the fix in four steps, each checked against the React, DOMPurify, react-markdown and Next.js docs.

  • 10 min read
  • Beginner
  • Oct 23, 2026
Voxel episode cover tagged EP. 22 on a deep burgundy background with faint blurred code: the headline reads HIS NAME RAN CODE? in cream and HIGH SCORE. in lime; in a retro arcade, a walnut cabinet’s HIGH SCORES screen has one row where orange pixel code spills out of the table and pours onto the floor, while Aster, in a black t-shirt with the lime lock logo and his closed silver laptop at his side, points at it in shock next to Claude, a terracotta-orange block with two black eye slits, two small white fangs, a red medallion and a black vampire cape, standing on six legs in cream-and-coral bowling shoes.

01WHAT HAPPENED

One player’s name took over the scoreboard

Why it mattersOne text box that shows HTML is enough. Every visitor runs whatever the worst user typed.

Here’s the story. Your app has a leaderboard, or profiles, where people pick a display name. You asked your AI agent to let names have bold and colours.

Illustration: in a bright retro arcade, a big HIGH SCORE screen in a walnut frame lists 1. AST and 2. CLD, but row 3 is a long string of orange code starting with <script> that spills out of the table and down the wall; Aster, in a black t-shirt with the lime lock logo and his closed silver laptop at his side, points at it with his mouth open, next to Claude, a terracotta-orange block with two black eye slits, two small white fangs, a red medallion and a black vampire cape, standing on six legs in cream-and-coral bowling shoes; far behind them, a faceless grey hooded figure, player two, stands at a cabinet.
Player two didn’t type a name. He typed code.
Open the full chapterIncludes a note

The quickest way to do that is to show each name as raw HTML. In React that’s dangerouslySetInnerHTML. It worked. Names came out bold and green.

Then a stranger typed code as his name instead of a name. Your app saved it in the database, like any name. Now every player who opens the scoreboard loads that name, and their browser runs his code.

What the code can do

React’s own docs: “The code embedded in the HTML will run. A hacker could use this security hole to steal user information or to perform actions on their behalf.”

It runs as your page, inside your visitor’s logged-in session. It can read what the page shows, send requests as that user, or swap your buttons for fake ones.

02WHY IT RUNS

React protects you, until you switch it off

Why it mattersReact already escapes text. The hole is the one place you told it not to.

By default, React is on your side. Its docs: “React DOM escapes any values embedded in JSX before rendering them.” And: “This helps prevent XSS (cross-site-scripting) attacks.”

Illustration: a row of five walnut arcade cabinets with tangerine and mustard side art, and on every sky-blue screen the same little orange code creature waves, copying itself from screen to screen; Aster, in a black t-shirt with the lime lock logo, holds his head with one hand, his closed silver laptop under his other arm, while Claude in his vampire cape and bowling shoes stands beside him with a nervous sweat drop.
One saved name. Every screen that shows it.
Open the full chapterIncludes code, a note

So <span>{name}</span> is safe. Whatever the name holds is shown as letters.

dangerouslySetInnerHTML switches that protection off for one element. React’s docs warn: “If the HTML inside isn’t trusted (for example, if it’s based on user data), you risk introducing an XSS vulnerability.”

✕ Don’tcomponents/PlayerName.tsx
// components/PlayerName.tsx: the hole
export function PlayerName({ player }) {
  // Whatever the player typed is treated as HTML, and any code in it runs.
  return <span dangerouslySetInnerHTML={{ __html: player.displayName }} />;
}

Raw HTML from a user. Any code in the name runs for every visitor.

✓ Docomponents/PlayerName.tsx
// components/PlayerName.tsx: the fix
export function PlayerName({ player }) {
  // React shows this as text. "<b>" appears as the letters <b>, it never runs.
  return <span>{player.displayName}</span>;
}

Plain text. React shows the tags as letters.

The same hole, other names

  • element.innerHTML = …, outerHTML, insertAdjacentHTML and document.write in plain JavaScript.
  • v-html in Vue and {@html …} in Svelte.
  • Markdown rendered with raw HTML switched on (for example marked output fed to innerHTML, or react-markdown with rehype-raw).
terminal
# Find every place your code shows text as HTML
grep -rnE "dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write|v-html|\{@html|rehype-raw" \
  --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" --include="*.vue" --include="*.svelte" \
  --exclude-dir=node_modules --exclude-dir=.next .

Lists every place to check. A hit isn’t a bug by itself: what matters is where the text comes from.

03STEP 1 · PLAIN TEXT

Show what people type as text, the normal React way

Why it mattersPlain text closes the hole for good. A name is only ever letters.

Most names, bios and comments don’t need HTML at all. Put the value in curly braces and let React print it.

Illustration: Aster, in a black t-shirt with the lime lock logo and his closed silver laptop in one hand, works the red joystick of a walnut arcade cabinet whose sky-blue screen shows a name box holding the plain letters <b> next to a big lime check mark; Claude in his vampire cape and six cream-and-coral bowling shoes stands beside the cabinet with a stack of prize tickets.
The tags show up as letters. Nothing runs.
Open the full chapterIncludes steps, a table, a note
  1. Find each dangerouslySetInnerHTML (and innerHTML) that shows text a user, your database or an AI wrote.
  2. Replace it with {value} inside the element, like <span>{player.displayName}</span>.
  3. Want names in colour? Style them in your own code (a class or a colour the user picks from a list), not with HTML the user types.
Where the text comes fromSafe way to show it
A user’s display name or bioPlain text: {value} in JSX
A comment or reviewPlain text, or react-markdown if you want bold and links
An AI chat answerreact-markdown without rehype-raw, or plain text. Never dangerouslySetInnerHTML
Markdown your users writereact-markdown (raw HTML off by default); add rehype-sanitize if you use plugins
HTML from a rich-text editorDOMPurify.sanitize right before you render it
Your own static HTML, written in your codeFine as it is: you wrote it, so you trust it

04STEP 2 · CLEAN IT FIRST

Need bold or links? Clean it, or use markdown

Why it mattersFormatting is fine. Running code is not. Clean it, or let markdown do the formatting.

Sometimes you really do want formatting: a bio with bold words, a comment with a link. Then clean the HTML before React shows it.

Illustration: Aster, in a black t-shirt with the lime lock logo, kneels by an arcade coin-changer labelled CLEAN, his silver laptop open on a bench, pointing at shiny gold tokens falling into the cup while spiky orange code bits are caught in a mesh tray at the side under a lime check mark; Claude in his vampire cape and bowling shoes holds a cup of clean tokens against his side nub.
Only clean tokens come out.
Open the full chapterIncludes code, a note

Option A: DOMPurify

DOMPurify describes itself as “a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG”. It strips what can run. The formatting stays.

components/PlayerBio.tsx
'use client';
import DOMPurify from 'dompurify';

export function PlayerBio({ bio }) {
  // Clean it right before you render it. Change nothing after this line.
  const clean = DOMPurify.sanitize(bio, { USE_PROFILES: { html: true } });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Install with npm install dompurify. USE_PROFILES: { html: true } keeps HTML only (no SVG or MathML).

Option B: react-markdown

If people write in markdown (or your AI answers in markdown), skip HTML altogether. The README: “Use of react-markdown is secure by default.”

components/Comment.tsx
import Markdown from 'react-markdown';

export function Comment({ text }) {
  // Bold, links and lists work. Raw HTML in the text is not rendered.
  // Don't add rehype-raw for text that users or an AI model wrote.
  return <Markdown>{text}</Markdown>;
}

The README: react-markdown “typically escapes HTML (or ignores it, with skipHtml)”.

05STEP 3 · THE SECOND WALL

Add a Content-Security-Policy header

Why it mattersIf a hole slips through later, the browser still has a list of what it may run.

A Content-Security-Policy (CSP) is a header that tells the browser which scripts, styles and images your page may load. Next.js docs: it guards “against various security threats such as cross-site scripting (XSS), clickjacking, and other code injection attacks.”

Illustration: at an arcade entrance with a burgundy velvet rope and brass posts, a faceless grey hooded figure, player two, throws a spiky tangle of orange code that bounces off a glowing lime glass wall; Claude in his vampire cape and bowling shoes stands inside the rope like a proud little bouncer, and Aster, in a black t-shirt with the lime lock logo, checks a clipboard behind a cream podium marked ALLOWED, his closed silver laptop under his arm.
Only what’s on the list gets in.
Open the full chapterIncludes code, a note

Here’s the simple version from the Next.js guide, set in next.config.js:

next.config.js
const isDev = process.env.NODE_ENV === 'development'

const cspHeader = `
    default-src 'self';
    script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
`

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader.replace(/\n/g, ''),
          },
        ],
      },
    ]
  },
}

The “Without Nonces” example from the Next.js CSP guide. It limits where scripts, styles, images and fonts can come from, and blocks plugins (object-src 'none') and framing (frame-ancestors 'none').

The strict version

For a stricter wall, the Next.js guide generates a fresh nonce for every request in proxy.ts, and only scripts carrying that nonce run. Inline code without it is blocked.

The guide is clear about the cost: every page then renders on each request, with no CDN caching by default. It suggests nonces when “You have strict security requirements that prohibit 'unsafe-inline'”.

06STEP 4 · THE TEST

Test it with a harmless name: <b>test</b>

Why it mattersBold means open. Tags you can read mean fixed.

You don’t need attack code to check your fix. Two bold tags are enough.

Illustration: Aster, in a black t-shirt with the lime lock logo, sits on a stool typing on his open silver laptop at a walnut arcade cabinet whose sky-blue screen shows a name box with the plain letters <b>test</b> and a big lime check mark below; Claude in his vampire cape and six cream-and-coral bowling shoes stands beside him with a referee whistle on his side.
Final round: the tags show as text. K.O.
Open the full chapterIncludes steps, a note
  1. Log in with your own test account.
  2. Set its display name (or bio, or a comment) to <b>test</b> and save.
  3. Open every page that shows it: the scoreboard, profiles, comments, emails, the admin view.
  4. You see the letters <b>test</b>: that page shows it as plain text. Fixed.
  5. You see the word test in bold: that page still renders it as HTML. Go back to step 1 or 2 for that page.

Paste the prompt at the top of this page into your coding agent to do the whole search for you. It lists every spot with its file and line, shows you the diff, and ends with this same test.

07REMATCH

The whole fix on one page

Why it mattersPlain text by default, clean when you must, a wall behind it, and a test to prove it.

Keep this next to your editor.

Illustration: in front of an arcade prize counter, a big HIGH SCORES screen with a lime check mark lists clean three-letter names; Aster, in a black t-shirt with the lime lock logo, smiles and gives a thumbs up with one hand, his closed silver laptop in the other, while Claude in his vampire cape and a ribbon of prize tickets lifts one leg to show off his cream-and-coral bowling shoe.
Rematch won. He keeps the shoes.
Open the full chapterIncludes a table, a note
You find thisFed by users, the database or AI?Do this
dangerouslySetInnerHTML={{ __html: value }}YesPlain {value}, or DOMPurify.sanitize right before it
el.innerHTML = value (or outerHTML, insertAdjacentHTML)Yesel.textContent = value, or sanitize first
v-html (Vue) / {@html} (Svelte)YesNormal text binding, or sanitize first
react-markdown with rehype-rawYesRemove rehype-raw; add rehype-sanitize if you need plugins
<Markdown>{text}</Markdown>YesFine: raw HTML is off by default
No Content-Security-Policy header—Add one in next.config.js as the second wall

THE QUICK READ

What to take away.
In one minute.

  1. 01

    If your app shows what people type as HTML, anyone can type code instead of a name. That code then runs in every visitor’s browser. This is stored cross-site scripting (XSS).

  2. 02

    React’s docs say it plainly: “The code embedded in the HTML will run. A hacker could use this security hole to steal user information or to perform actions on their behalf.”

  3. 03

    It’s a common AI mistake. In Veracode’s 2025 tests, AI code failed to block cross-site scripting 86% of the time.

  4. 04

    Step 1: show user text as plain text, {value} in JSX. React prints <b> as the letters <b>.

  5. 05

    Step 2: need formatting? DOMPurify.sanitize right before rendering, or react-markdown without rehype-raw.

  6. 06

    Step 3: a Content-Security-Policy header is a second wall, not the fix. The simple Next.js version still allows inline scripts.

  7. 07

    Step 4: set a test account’s name to <b>test</b>. Bold means you’re still open. The tags shown as text means it’s fixed.

THE FIX, IN ONE PASTE

Paste this. Find every name that can run code.

Paste this into Claude Code, Cursor or any coding agent. It lists every place your app shows text as HTML, with its file and line, and marks the ones fed by users, the database or AI answers. It shows you the diff before it changes anything, and tells you how to test.

agent-prompt.md
Check my app for places where text from users is shown as HTML (cross-site scripting).
1. Search the code for dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML, document.write, v-html and {@html}, and for markdown rendered with raw HTML (marked, rehype-raw). List each one with its file and line.
2. For each one, tell me where the text comes from. If any of it comes from a user, the database or an AI model's answer, mark it unsafe.
3. Fix each unsafe one: if no formatting is needed, show it as plain text ({value} in JSX). If formatting is needed, clean it with DOMPurify.sanitize right before rendering, or render it with react-markdown without rehype-raw.
4. Add a Content-Security-Policy header (Next.js: headers() in next.config.js) and tell me in plain words what it allows.
5. Change nothing else. Show me the diff before you apply it, then tell me how to test: set my display name to <b>test</b>; it must show the tags as text, not bold.

Paste it into the agent you already build with

  • Claude
  • ChatGPT
  • Gemini
  • Cursor
  • Copilot

QUESTIONS PEOPLE ASK

Good questions.
Short answers.

Doesn’t React already escape everything?

Yes, for normal values like {name}: React shows them as text. Not for dangerouslySetInnerHTML. That prop tells React to treat the string as HTML, so any code in it can run. React’s docs: “Only use dangerouslySetInnerHTML with trusted and sanitized data.”

What is stored XSS?

Cross-site scripting where the code is saved in your app, for example in a display name or a comment in your database. Every visitor who loads it runs it. OWASP: “the injected script is permanently stored on the target servers”.

Is markdown safe?

With react-markdown, yes by default: its README says it is “secure by default” and it escapes raw HTML. It stops being safe if you add rehype-raw for text you don’t trust, or change urlTransform to something insecure. Other markdown tools that output an HTML string still need sanitizing before you insert it.

Do I still need DOMPurify if I have a CSP?

Yes. The CSP is a second wall. The simple Next.js version still allows inline scripts with 'unsafe-inline'. Fix the source first: plain text, or sanitize with DOMPurify. Then the CSP catches what slips through.

What about AI chatbot answers?

Treat them like anything a stranger typed. A model’s answer can carry a tag that someone slipped into the prompt or into a page the model read. Show it with react-markdown (no rehype-raw) or as plain text, never with dangerouslySetInnerHTML.

How do I test my app safely?

Set your own test account’s name to <b>test</b>. If it shows up bold, that page still renders HTML. If you see the tags as text, it’s plain text. Never use real attack code, and never test on other people’s accounts.

Can I use DOMPurify in a Next.js server component?

DOMPurify needs a DOM. In the browser that’s built in; on the server the README recommends jsdom (the latest version), or isomorphic-dompurify, which handles both. The simplest path is sanitizing in a client component, right before rendering.

Does Aster find this in my code?

Aster’s audit flags dynamic HTML passed to dangerouslySetInnerHTML, and dynamic values assigned to innerHTML, when no recognised sanitizer is in between. It reads your repository; it doesn’t run your app, test your CSP header, or prove a page is safe.

Done with the guide?
Let Aster check the rest.

1 free audit every month. Aster reads your code, env files, database rules and packages. You get each finding in plain English, with the file and line.

Aster

CHECKING

YOUR GUIDE TO ASTER

Hi, I’m Aster.
Ask me what Aster checks.

I can’t see your code from here. Please don’t paste secrets or private code.

Prepared answers are always available.