GUIDESEPISODES
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.

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.

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.”

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.”
// 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.
// 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,insertAdjacentHTMLanddocument.writein plain JavaScript.v-htmlin Vue and{@html …}in Svelte.- Markdown rendered with raw HTML switched on (for example
markedoutput fed toinnerHTML, orreact-markdownwithrehype-raw).
# 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.

Open the full chapterIncludes steps, a table, a note
- Find each
dangerouslySetInnerHTML(andinnerHTML) that shows text a user, your database or an AI wrote. - Replace it with
{value}inside the element, like<span>{player.displayName}</span>. - 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 from | Safe way to show it |
|---|---|
| A user’s display name or bio | Plain text: {value} in JSX |
| A comment or review | Plain text, or react-markdown if you want bold and links |
| An AI chat answer | react-markdown without rehype-raw, or plain text. Never dangerouslySetInnerHTML |
| Markdown your users write | react-markdown (raw HTML off by default); add rehype-sanitize if you use plugins |
| HTML from a rich-text editor | DOMPurify.sanitize right before you render it |
| Your own static HTML, written in your code | Fine 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.

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.
'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.”
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.”

Open the full chapterIncludes code, a note
Here’s the simple version from the Next.js guide, set in 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.

Open the full chapterIncludes steps, a note
- Log in with your own test account.
- Set its display name (or bio, or a comment) to
<b>test</b>and save. - Open every page that shows it: the scoreboard, profiles, comments, emails, the admin view.
- You see the letters
<b>test</b>: that page shows it as plain text. Fixed. - 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.

Open the full chapterIncludes a table, a note
| You find this | Fed by users, the database or AI? | Do this |
|---|---|---|
dangerouslySetInnerHTML={{ __html: value }} | Yes | Plain {value}, or DOMPurify.sanitize right before it |
el.innerHTML = value (or outerHTML, insertAdjacentHTML) | Yes | el.textContent = value, or sanitize first |
v-html (Vue) / {@html} (Svelte) | Yes | Normal text binding, or sanitize first |
react-markdown with rehype-raw | Yes | Remove rehype-raw; add rehype-sanitize if you need plugins |
<Markdown>{text}</Markdown> | Yes | Fine: 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.
- 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).
- 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.”
- 03
It’s a common AI mistake. In Veracode’s 2025 tests, AI code failed to block cross-site scripting 86% of the time.
- 04
Step 1: show user text as plain text,
{value}in JSX. React prints<b>as the letters<b>. - 05
Step 2: need formatting?
DOMPurify.sanitizeright before rendering, orreact-markdownwithoutrehype-raw. - 06
Step 3: a Content-Security-Policy header is a second wall, not the fix. The simple Next.js version still allows inline scripts.
- 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.
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.
Plain text by default, clean it when you need formatting, add the second wall, and test with <b>test</b>. When you want a second look at the rest of your code, your first repository audit is free: Aster flags dynamic HTML passed to dangerouslySetInnerHTML, plus leaked secrets, Supabase policies and vulnerable packages in your repo.
Source notebook7 links
- React Docs: Common components, dangerouslySetInnerHTML (“The code embedded in the HTML will run”)
- OWASP: Cross Site Scripting (XSS), stored and reflected attacks
- Veracode: 2025 GenAI Code Security Report press release (July 30, 2025)
- React legacy docs: JSX prevents injection attacks (“React DOM escapes any values embedded in JSX”)
- DOMPurify README (cure53): usage, server side with jsdom, foot-guns
- react-markdown README: security, HTML in markdown, rehype-raw, rehype-sanitize
- Next.js Docs: Content Security Policy (nonces with proxy.ts, or headers in next.config.js)










