LEAKCADE

XSS in React: what AI-generated code gets wrong

Updated 8 min read

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 stored XSS reaches your usersAn attacker posts a comment containing HTML. Your app saves it unchanged, renders it with dangerouslySetInnerHTML, and the attacker's script runs in every visitor's browser as that user.INPUTAttacker posts acomment or bioSTORESaved as is inyour databaseRENDERdangerouslySetInnerHTML shows itRUNScript runs asthe logged inuserINPUTAttacker posts a comment or bioSTORESaved as is in your databaseRENDERdangerouslySetInnerHTML shows itRUNScript runs as the logged in user
One unescaped render turns a text field into code execution.

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.

The pattern you'll see most trusts whatever HTML arrives.

BEFORERaw HTML from a user
// components/Comment.tsx
export function Comment({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}
AFTERSanitized right before rendering
// components/Comment.tsx
import DOMPurify from "isomorphic-dompurify"

export function Comment({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html)
  return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
If you don't need formatting, render the text as a normal JSX child and skip the sanitizer.

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.

BEFOREMarkdown with raw HTML switched on
import Markdown from "react-markdown"
import rehypeRaw from "rehype-raw"

export function Reply({ text }: { text: string }) {
  return <Markdown rehypePlugins={[rehypeRaw]}>{text}</Markdown>
}
AFTERDefault react-markdown
import Markdown from "react-markdown"

export function Reply({ text }: { text: string }) {
  return <Markdown>{text}</Markdown>
}
Need raw HTML in markdown? Use rehypePlugins={[rehypeRaw, rehypeSanitize]} with the rehype-sanitize package.

Plain react-markdown builds React elements, so there's no HTML string to attack. Three rules apply:

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.

  1. 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=\{" .
  2. Render as text first

    Replace dangerouslySetInnerHTML and innerHTML with {value} in JSX or textContent wherever you don't need formatting.

  3. Sanitize the HTML you truly need

    Pass it through DOMPurify.sanitize() right before rendering and don't edit the result afterwards. Keep dompurify and jsdom updated.

  4. Check every URL

    For href and src values that come from users, APIs or an LLM, allow only http: and https: (add mailto: if you need it). The safeHref helper above does this.

  5. 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 doesWhere it falls short
Escaping and DOMPurifyStops input from becoming code. This is the main defense.Fails if you skip it in one place or edit the HTML afterwards.
Content Security PolicyBackup: tells the browser which scripts it may run.A loose policy that allows inline scripts blocks little.
HttpOnly cookie instead of localStorageScript 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.

  1. Run the grep from step 1 and open every hit.
  2. 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.
  3. 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 which href React actually rendered.
  4. 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.
  5. If your app shows LLM replies, ask the assistant to repeat <img src=x onerror=alert(1)> exactly and watch how the reply renders.
  6. 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.

FIX PROMPT
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

  1. OWASP: Cross Site Scripting Prevention Cheat Sheet
  2. MDN: Cross-site scripting (XSS)
  3. React docs: Common components (dangerouslySetInnerHTML)
  4. DOMPurify on GitHub
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.