SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/XSS via dangerouslySetInnerHTML
CWE-79A05:2025 InjectionHigh severityNext.js

XSS via dangerouslySetInnerHTML

◐React escapes JSX interpolations automatically, and SecureStartKit ships a Content Security Policy that limits XSS impact, but dangerouslySetInnerHTML on unsanitized input bypasses both defenses.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

React escapes values interpolated through JSX by default, so curly-brace interpolation is safe. dangerouslySetInnerHTML bypasses that escaping. If the HTML comes from user-controlled data and is not sanitized, an attacker can inject script-equivalent vectors that execute in every other visitor session. Fix: run the HTML through DOMPurify (use isomorphic-dompurify on the server) before passing it to __html, or render Markdown through a pipeline that strips raw HTML.

Where it shows up: dangerouslySetInnerHTML is set from user-controlled HTML or from Markdown-to-HTML output without first running the string through a sanitizer such as DOMPurify.

The vulnerable patterns and their fixes

Stored bio rendered with dangerouslySetInnerHTML

✗Vulnerabletsx
// components/user-bio.tsx
export function UserBio({ bio }: { bio: string }) {
  // bio is user-controlled, stored in the DB, never sanitized
  return (
    <div
      className="prose"
      dangerouslySetInnerHTML={{ __html: bio }}
    />
  )
}

bio comes directly from the database with no sanitization. Any HTML the user stored, including onerror handlers and javascript: URLs, is rendered as live markup.

↓the fix
✓Securetsx
// components/user-bio.tsx
import DOMPurify from 'isomorphic-dompurify'

export function UserBio({ bio }: { bio: string }) {
  const clean = DOMPurify.sanitize(bio, { USE_PROFILES: { html: true } })
  return (
    <div
      className="prose"
      dangerouslySetInnerHTML={{ __html: clean }}
    />
  )
}

isomorphic-dompurify runs on both the server (Node.js) and the browser. DOMPurify strips event handlers, javascript: URLs, and other dangerous constructs while preserving legitimate formatting tags. Restrict further with ALLOWED_TAGS if the bio only needs a small tag set.

Markdown converted to HTML then injected raw

✗Vulnerabletsx
// components/article-body.tsx
import { marked } from 'marked'

export function ArticleBody({ markdown }: { markdown: string }) {
  const html = marked(markdown) as string
  // marked passes raw HTML blocks through by default
  return (
    <article
      className="prose"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  )
}

marked passes raw HTML through by default. An attacker writes an img onerror tag directly in Markdown and the renderer outputs it unchanged. The resulting HTML is injected without sanitization.

↓the fix
✓Securetsx
// components/article-body.tsx
import { marked } from 'marked'
import DOMPurify from 'isomorphic-dompurify'

export function ArticleBody({ markdown }: { markdown: string }) {
  const rawHtml = marked(markdown) as string
  const clean = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } })
  return (
    <article
      className="prose"
      dangerouslySetInnerHTML={{ __html: clean }}
    />
  )
}

Sanitizing after rendering catches injections whether they come from raw HTML blocks or renderer quirks. If users have no legitimate reason to write raw HTML, also disable HTML passthrough in the renderer for defense in depth.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

An attacker registers and sets their public bio to an image tag with an onerror handler that reads document.cookie and sends it to an attacker-controlled server.

The app stores that string in the database and later renders the profile page with dangerouslySetInnerHTML set to user.bio. React outputs the raw string into the DOM. The browser parses the img tag, the src fails, and the onerror handler fires immediately. Every visitor who loads the profile page sends their session cookie to the attacker.

Because the payload is stored in the database before it fires, this is stored (persistent) XSS. The attacker does not need to trick anyone into clicking a crafted link. The payload executes automatically on every page load.

A Markdown variant works the same way. If you pass user-authored Markdown through a renderer like marked without disabling raw HTML, the attacker writes the same img tag inside a Markdown document. The renderer passes it through verbatim, and the final HTML is injected with dangerouslySetInnerHTML.

A Content Security Policy that restricts script sources and blocks inline event handlers raises the bar significantly, but CSP alone is not a complete defense: data URIs, JSONP endpoints, and overly broad allowlist entries are commonly exploited to bypass it.

How to find it in your code

Search the codebase for every use of dangerouslySetInnerHTML:

grep -rn "dangerouslySetInnerHTML" --include="*.tsx" --include="*.jsx" .

For each match, trace the __html value back to its origin. If the value ever flows from a database column, API response, URL parameter, or any field the user can write, it is a candidate for XSS.

Confirm that DOMPurify.sanitize (or an equivalent server-side sanitizer such as sanitize-html) is called immediately before the assignment. A sanitize call elsewhere in the data pipeline does not count: it must wrap the value passed to __html.

Also search for Markdown renderers (marked, remark, rehype, showdown, micromark). For each one, verify that raw HTML passthrough is disabled or that the output is sanitized before being passed to dangerouslySetInnerHTML.

Finally, check your Content Security Policy in next.config.ts or the proxy. A strict script-src that blocks inline event handlers (no unsafe-inline) provides meaningful defense in depth even if a sanitizer is misconfigured.

Common mistakes

  • Myth“React prevents XSS, so dangerouslySetInnerHTML is safe.”

    React prevents XSS only for values interpolated through JSX. dangerouslySetInnerHTML explicitly opts out of that protection. The name exists to make the risk obvious; it adds no sanitization of its own.

  • Myth“Sanitizing the input before storing it in the database is enough.”

    Sanitize at the point of rendering, not only at the point of storage. Data can enter the database through import scripts, direct inserts, or older code paths that lacked sanitization. Sanitize immediately before calling dangerouslySetInnerHTML regardless of what the storage layer does.

  • Myth“A Content Security Policy blocks XSS completely, so sanitization is optional.”

    CSP is an important layer, not a complete defense. Attackers bypass it through JSONP endpoints on allowlisted domains, overly broad allowlist entries, and data URI tricks. Sanitization and CSP are complementary.

  • Myth“DOMPurify only works in the browser, so you cannot use it in Server Components.”

    isomorphic-dompurify wraps DOMPurify with a DOM implementation for Node.js. It runs in Server Components, route handlers, and Server Actions. Import from isomorphic-dompurify and the correct implementation is selected at runtime.

Does SecureStartKit prevent this?

Normal JSX interpolation is safe in every component the kit ships. The risk arises only if you add a dangerouslySetInnerHTML call and pass it unsanitized user content. The kit default Content Security Policy raises the bar for exploitation (inline event handlers are blocked) but CSP alone cannot prevent injection. You must sanitize with DOMPurify at the call site.

The kit CSP and escaping defaults→

Frequently asked questions

Is curly-brace interpolation in JSX safe from XSS?
Yes. React converts the value to a string and escapes HTML special characters before inserting it into the DOM. An attacker who stores a script tag in the database sees it rendered as visible text, not executed as markup. This applies to all JSX interpolations and requires no extra configuration.
When is dangerouslySetInnerHTML actually necessary?
The main legitimate uses are rendering user-authored rich text (blog posts, bios, formatted comments) and rendering HTML returned by a CMS or Markdown pipeline. If the content is plain text, render it with normal interpolation and avoid dangerouslySetInnerHTML entirely.
Which sanitizer should I use?
DOMPurify via isomorphic-dompurify is the most widely audited option and works in both Node.js and the browser. sanitize-html is a reasonable alternative with a tag-allowlist API. Avoid writing your own regex-based sanitizer; attackers routinely bypass them with encoding tricks and parser differentials.
Does a strict Content Security Policy replace sanitization?
No. CSP tells the browser which script sources are trusted, blocking many payloads after injection. Sanitization prevents the malicious markup from entering the DOM in the first place. A strict CSP with nonces or hashes raises the bar, but attackers regularly find bypasses, so both layers are needed.

References

  • CWE-79: Improper Neutralization of Input During Web Page Generation ↗
  • OWASP XSS Prevention Cheat Sheet ↗

Related weaknesses

  • SQL Injection in Supabase QueriesUser input is interpolated into a .or() or .filter() string, or concatenated into a Postgres function’s dynamic SQL, instead of being passed as a bound value.
  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.
  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.

Defined terms

  • XSS
  • Content Security Policy
  • CSRF

Go deeper

  • Render User HTML and Markdown in Next.js Without XSS
  • Next.js Security Headers Generator

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns