XSS in React: what AI-generated code gets wrong
XSS in React mostly happens when you skip the escaping JSX does for you. AI tools skip it with dangerouslySetInnerHTML, markdown that allows raw HTML, user-supplied href values and innerHTML in plain scripts. Render untrusted content as text, sanitize any HTML with DOMPurify, and allow only http and https links.
QUICK CHECKLIST
- Grep for dangerouslySetInnerHTML and innerHTML
- Render user and LLM text as plain JSX text
- Sanitize HTML you must render with DOMPurify, last
- Drop rehype-raw or add rehype-sanitize
- Allow only http and https in href and src
- Add a CSP and keep tokens out of localStorage
What XSS in React is and why AI code brings it back
Cross-site scripting (XSS) means an attacker gets your site to run their JavaScript as if it were yours. MDN lists what that code can do: change anything on the page, read local storage, and make requests with the user's credentials. In plain words, it can act as the logged-in user.
Every XSS bug needs two things: input an attacker can craft, and a page that includes it without making sure it can't run as code.
React handles the second part for you in the normal case. A value in JSX like {comment.body} is encoded, so <img src=x onerror=...> shows up as visible text.
The trouble starts at the escape hatches, the features that skip that encoding. OWASP names the usual ones for React: dangerouslySetInnerHTML without sanitizing, and URL values nobody validated.
AI tools reach for those hatches because they make a feature work in one line. Ask for "comments with bold text and links" and you can end up with dangerouslySetInnerHTML or a markdown renderer with raw HTML switched on.
How an attacker uses it
Look at every place where text from someone else lands on a page. Each one is a spot to test.
- Comments, bios, reviews and post titles. They're saved once and shown to every visitor. MDN calls this stored XSS and says it's particularly severe, because everyone who opens the page gets the payload.
- Link fields. A website field rendered as
<a href={user.website}>can hold ajavascript:URL with code after it, and the code runs on click. OWASP says React 19 blocksjavascript:URLs insrcandhref. On older versions you need your own check, and even React 19 doesn't replace validating URLs yourself. - Markdown.
react-markdownis safe by default and doesn't usedangerouslySetInnerHTML. Therehype-rawplugin changes that, because it turns raw HTML inside the markdown into real elements. - LLM output. A user, or a web page the model read, can steer a reply into writing an
<img onerror>tag. If your chat UI renders replies as HTML, that's XSS. Prompt injection explains how the model gets steered. - Plain scripts. MDN's example is a welcome message built with
innerHTMLfrom a?user=URL parameter. A crafted link makes the page run the attacker'sonerrorhandler.
Before and after: dangerouslySetInnerHTML, markdown and links
The pattern you'll see most trusts whatever HTML arrives.
// components/Comment.tsx
export function Comment({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />
}// components/Comment.tsx
import DOMPurify from "isomorphic-dompurify"
export function Comment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html)
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}Render as text if you can. If a rich text editor really produces HTML, sanitize it (strip anything that could run as code) right before rendering. OWASP recommends DOMPurify: DOMPurify.sanitize(dirty) returns a clean string.
Next.js renders client components on the server too, so use isomorphic-dompurify, which the DOMPurify README mentions, or DOMPurify with an up-to-date jsdom (a server-side DOM library). Both need the Node.js runtime, because jsdom does not run on Edge runtimes such as Vercel Edge.
Markdown is next. A common shortcut is adding rehype-raw so HTML tags inside markdown work.
import Markdown from "react-markdown"
import rehypeRaw from "rehype-raw"
export function Reply({ text }: { text: string }) {
return <Markdown rehypePlugins={[rehypeRaw]}>{text}</Markdown>
}import Markdown from "react-markdown"
export function Reply({ text }: { text: string }) {
return <Markdown>{text}</Markdown>
}Plain react-markdown builds React elements, so there's no HTML string to attack. Three rules apply:
- If you truly need raw HTML in markdown, put
rehype-sanitizeafterrehype-rawin the plugin list. - Leave
urlTransformalone. The default,defaultUrlTransform, filters URLs with unsafe protocols, and replacing it with(url) => urlremoves that filter. - Libraries like
markedgive you an HTML string instead of React elements, so sanitize that string with DOMPurify before you inject it.
Links need an allowlist. Check the protocol, and drop the href if it isn't http or https.
export function safeHref(url: string): string | undefined {
try {
const { protocol, href } = new URL(url)
return protocol === "https:" || protocol === "http:" ? href : undefined
} catch {
return undefined // not an absolute URL
}
}
// <a href={safeHref(user.website)}>Website</a>In plain JavaScript, swap innerHTML for textContent. Then a value like <img src=x onerror=...> is shown as text and never becomes an element.
How to fix XSS in React, step by step
Work through these in order. Steps 1 to 4 remove the bug, step 5 limits the damage if you miss one.
Find every place text becomes HTML or a link
Search the repo for places where text becomes HTML or a link, then check where each value comes from.
grep -rnE --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" --exclude-dir=node_modules --exclude-dir=.next "dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write|rehype-raw|rehypeRaw|urlTransform|href=\{|src=\{" .Render as text first
Replace
dangerouslySetInnerHTMLandinnerHTMLwith{value}in JSX ortextContentwherever you don't need formatting.Sanitize the HTML you truly need
Pass it through
DOMPurify.sanitize()right before rendering and don't edit the result afterwards. Keepdompurifyand jsdom updated.Check every URL
For
hrefandsrcvalues that come from users, APIs or an LLM, allow onlyhttp:andhttps:(addmailto:if you need it). ThesafeHrefhelper above does this.Add the backups
Set a Content Security Policy and move session tokens out of localStorage. Both limit the damage if something slips through.
Run the grep again after your AI tool makes changes. Tools tend to fix the one component you pointed at and leave the same pattern in others.
Backup defenses: Content Security Policy and tokens in localStorage
Fixing the unsafe render spots is the main job. Two backups limit the damage when one slips through.
A Content Security Policy tells the browser which scripts it may run. MDN calls it a backup defense: if executable input reaches a page, a properly configured policy should stop the browser from running it. A policy that allows inline scripts helps much less, so see security headers in Next.js for a setup that fits the framework.
Where your session token lives matters too. MDN says injected code can read local storage, so a localStorage token is one XSS bug away from being stolen. An HttpOnly cookie (one the browser hides from scripts) can't be read by injected code, though that code can still send requests with the user's credentials. The guide on JWT in localStorage vs cookies covers the trade-off.
| What it does | Where it falls short | |
|---|---|---|
| Escaping and DOMPurify | Stops input from becoming code. This is the main defense. | Fails if you skip it in one place or edit the HTML afterwards. |
| Content Security Policy | Backup: tells the browser which scripts it may run. | A loose policy that allows inline scripts blocks little. |
| HttpOnly cookie instead of localStorage | Script can't read the session token. | Injected script can still make requests as the user. |
Use all three. Only the first one prevents the bug.
Test it yourself
Only test your own app, ideally on localhost or a staging copy with a test account. The test inputs below make the browser show a harmless alert box.
- Run the grep from step 1 and open every hit.
- In each text field that shows up elsewhere (comment, bio, title, chat message), save
<img src=x onerror=alert(1)>and open the page where it displays. Bad result: an alert box appears. Good result: no alert, and DevTools shows no onerror attribute on the element. You'll see raw text, a broken image icon, or nothing, depending on the fix. - In any link or website field, save
javascript:alert(1)and click the rendered link. Bad result: an alert. Good result: nothing happens, or no link is rendered. Inspect the element in DevTools to see whichhrefReact actually rendered. - In markdown inputs, post
[click me](javascript:alert(1))and, on a separate line,<img src=x onerror=alert(1)>. Bad result: an alert. Good result: the link has an empty or missing href, and the img tag is dropped or shown as text. - If your app shows LLM replies, ask the assistant to repeat
<img src=x onerror=alert(1)>exactly and watch how the reply renders. - Open DevTools, Application tab, Local Storage. If you see an access token or session value, plan to move it.
Fix prompt you can paste
Give this to Cursor or Claude Code. It asks for locations first, so you can review each hit before the changes land.
Audit this repo for XSS. Search all .ts, .tsx, .js and .jsx files (skip node_modules and .next) for: dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML, document.write, rehype-raw or rehypeRaw, urlTransform overrides on react-markdown, and any href, src or window.location value that comes from user data, API responses or LLM output.
For each hit, output the file path and line number, where the value comes from, and whether an attacker or an LLM could control it.
Stop after this list and wait for my confirmation. Once I confirm, fix them. Render as text ({value} in JSX or textContent) whenever formatting isn't needed. Where real HTML is required, sanitize it with DOMPurify (isomorphic-dompurify in Next.js) right before rendering and don't modify the string afterwards. Remove rehype-raw, or add rehype-sanitize after it. Add a safeHref helper that allows only http: and https: URLs and use it for every user-supplied link.
Also list any auth tokens stored in localStorage or sessionStorage.
Explain each change in one or two sentences. Don't touch unrelated code.Afterwards, you can scan the repo with Leakcade.
Further reading
- OWASP: Cross Site Scripting Prevention Cheat Sheet
- MDN: Cross-site scripting (XSS)
- React docs: Common components (dangerouslySetInnerHTML)
- DOMPurify on GitHub
- react-markdown on GitHub
FAQ
Is React safe from XSS by default?
Mostly. Values in JSX are encoded, so user text shows up as text. The gaps are escape hatches like dangerouslySetInnerHTML without sanitizing and URL values you never validated, as OWASP points out.
Does React block javascript: URLs in href?
OWASP says React 19 blocks javascript: URLs in src and href, but that doesn't replace your own URL validation. Check which React version you run, and allow only http and https for user-supplied links either way.
Is DOMPurify enough for dangerouslySetInnerHTML?
Yes, for HTML you must render, if you use it correctly. OWASP recommends DOMPurify for sanitizing HTML. Call it right before rendering and don't modify the string afterwards. If you don't need formatting, render plain text and skip it.
Do I need DOMPurify with react-markdown?
Not if you leave raw HTML off. react-markdown is safe by default and doesn't use dangerouslySetInnerHTML. If you add rehype-raw, sanitize with rehype-sanitize. If a library gives you an HTML string, sanitize that string with DOMPurify.
Is it safe to render LLM output as HTML?
No. Treat model output as untrusted text, because users or poisoned content can steer it into writing HTML tags. Render it as text or as markdown without raw HTML.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms