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