Skip to content
All guides

dangerouslySetInnerHTML and XSS: stop user content running as code in your app

By Obscura Security · Updated · React, Next.js, Vue, Cursor, Lovable, Bolt

Short answer

If your app puts user-supplied or AI-generated text into the page as HTML (dangerouslySetInnerHTML, v-html, an unsafe markdown renderer, or a link whose URL the user chose), an attacker can plant a script that runs in other people's browsers. Let React escape text by default, sanitise with DOMPurify when you truly need HTML, and only allow http and https links.

Cross-site scripting (XSS) is what happens when text a user typed ends up on the page as HTML, and the browser runs it. If the attacker’s text is shown to other people, such as a comment, a profile bio or a support ticket, the script runs as them, with access to their session.

Why this happens

React escapes everything you render with {value}. XSS in a React or Next.js app almost always comes from one of four places where that protection is skipped.

dangerouslySetInnerHTML. AI tools reach for it whenever you ask for “render the formatted text” or “show the HTML from the editor”:

<div dangerouslySetInnerHTML={{ __html: comment.body }} />

If comment.body came from a user, it is now their HTML on your page. Vue’s v-html does the same thing.

Markdown rendered to HTML without sanitising. Some libraries (marked, for example) turn markdown into an HTML string and leave sanitising to you. Markdown allows raw HTML inside it, so the result can carry a script.

AI output rendered as HTML. A model’s reply is built from its input, and that input can include text an attacker controls. See prompt injection in AI features.

Links built from user input. A profile “website” field that goes straight into an href:

<a href={profile.website}>My website</a>

A value beginning javascript: runs code when someone clicks. React warns about this in development, but don’t count on the warning to protect you.

The usual consequence is a stolen session. Anything the page’s JavaScript can read, the injected script can read too, including tokens kept in localStorage.

How to check your own app

Do this on your own app, with your own test account.

  1. Search your code for dangerouslySetInnerHTML, v-html, innerHTML and href={. For each hit, trace where the value comes from. If a user or an AI model can influence it, that is a finding.
  2. Find a field whose value is shown back on a page (a name, bio, comment, post title). Save <b>test</b> in it. If the page shows test in bold, it is being rendered as HTML. If you see the literal tags, it is being escaped, which is what you want.
  3. If step 2 shows bold text, confirm the risk by saving <img src=x onerror=alert(1)> and reloading. An alert box means a script can run. (Scripts inserted with innerHTML do not run, which is why this test uses an image.) Delete the test data afterwards.
  4. For URL fields, save javascript:alert(1) as the value, then click the link yourself.

The fix

1. Render text as text

If you do not need formatting, remove dangerouslySetInnerHTML and render the value normally:

<p>{comment.body}</p>

To keep line breaks, add style={{ whiteSpace: 'pre-wrap' }} instead of converting to HTML.

2. Sanitise when you need real HTML

If users genuinely supply formatted content, such as output from a rich-text editor, pass it through DOMPurify before rendering:

import DOMPurify from 'dompurify'

const clean = DOMPurify.sanitize(comment.body)
return <div dangerouslySetInnerHTML={{ __html: clean }} />

DOMPurify needs a DOM. If the component renders on the server in Next.js, use isomorphic-dompurify, which works in both places. Sanitise at the point of output, every time, even if you also sanitised when saving.

3. Use a markdown renderer that ignores raw HTML

react-markdown does not render raw HTML in the markdown unless you add the rehype-raw plugin. Leave that plugin out:

import ReactMarkdown from 'react-markdown'

<ReactMarkdown>{message.content}</ReactMarkdown>

For markdown-it, keep the html option off (its default). If you use marked, run its output through DOMPurify. This also applies to chatbot replies.

Parse the URL and check the protocol before you render it:

export function safeHttpUrl(input: string): string | null {
  try {
    const url = new URL(input)
    return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null
  } catch {
    return null
  }
}
const href = safeHttpUrl(profile.website)
return href ? <a href={href} rel="noopener noreferrer">My website</a> : null

Use an allow-list of protocols. A block-list that looks for the word javascript is easy to get around.

5. Add a Content-Security-Policy as a backstop

A CSP tells the browser which scripts it may run. A policy without unsafe-inline for scripts stops most injected inline scripts even when one slips through. Next.js supports nonce-based CSPs, and its documentation covers the setup. Start with the missing security headers guide if you have no CSP yet.

Mistakes to avoid

  • Sanitising only when saving. Rules change and old rows stay in the database. Sanitise when you render.
  • Writing your own sanitiser. Stripping <script> tags with a regex misses event handlers like onerror. Use DOMPurify.
  • Trusting HTML from your own database. If any user can write to that column, it is user input.
  • Allowing SVG or HTML in rich content. Both can carry scripts. Keep the allow-list of tags short.
  • Forgetting the AI path. Check how chat replies, summaries and generated emails get displayed, not only what users type directly.

Common questions

Is dangerouslySetInnerHTML always unsafe?
It is safe only when the HTML comes from you, or has been through a sanitiser such as DOMPurify. If any part of the string came from a user, a database row a user can write to, a URL, or an AI model, treat it as unsafe until sanitised.
Does React protect me from XSS automatically?
For normal text rendered as {value}, yes. React escapes it. The protection is switched off by dangerouslySetInnerHTML, and it does not cover links built from user input, such as href={userUrl}, or HTML you build yourself and insert with innerHTML.
Is it safe to render a chatbot's reply as HTML?
No. A model's output can contain whatever an attacker managed to get into its input. Render it as plain text or through a markdown renderer that ignores raw HTML.
Do I still need a Content-Security-Policy if I sanitise everything?
It is worth having. A CSP limits what an injected script can do if you miss one spot. It is a second layer, and it does not replace fixing the rendering.

Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].