React has shipped automatic text escaping since its earliest public releases, and that single default has quietly prevented more reflected and stored XSS than almost any other framework decision of the last decade. Every value interpolated inside JSX curly braces — {userName}, {comment.body} — is converted to a string and escaped before it touches the DOM, so a payload like <script>alert(1)</script> renders as inert text, not executable markup. But that protection has hard edges, and attackers who understand React target exactly those edges: the dangerouslySetInnerHTML prop, which injects raw HTML by design; URL-bearing attributes like href and src, which JSX escaping never sanitizes for scheme; and the npm dependency tree every React app is built on, which in September 2025 became the delivery mechanism for a self-propagating worm that CISA confirmed had compromised more than 500 packages. This post walks through where React's built-in protection actually ends, how to sanitize the cases where raw HTML rendering is unavoidable, and how the 2025 npm supply-chain incidents changed what "secure dependencies" has to mean for any team shipping a React frontend today.
Where does React's automatic escaping actually stop protecting you?
React's escaping is scoped narrowly to text nodes rendered through JSX interpolation — it does not extend to attribute values that carry executable meaning, or to any API that writes raw strings into the DOM. <a href={userSuppliedUrl}> is a common failure mode: if userSuppliedUrl is javascript:alert(document.cookie), React renders that string into the href attribute unmodified, because escaping HTML entities does nothing to a URL scheme. Invicti's security research on React XSS has documented this pattern directly, noting it as a live vector distinct from dangerouslySetInnerHTML because developers assume "JSX escapes everything" and stop checking. The fix is a scheme allowlist applied before the value ever reaches JSX — accept https:, mailto:, and relative paths, and reject or strip anything else, including javascript:, data:, and vbscript: variants that browsers still parse case-insensitively.
Why is dangerouslySetInnerHTML the sink every React security review should start with?
dangerouslySetInnerHTML is named the way it is because the React team wants every reviewer to stop at it — it takes a raw HTML string and writes it directly into the DOM via innerHTML, bypassing React's escaping entirely by design. Pragmatic Web Security's "Preventing XSS in React" research and Invicti's framework-specific writeups both converge on the same guidance: never pass user-controlled or API-derived HTML into this prop without sanitization first, because there is no React-level safeguard behind it — the string you pass is the string that executes. In practice this shows up most often in CMS-driven content, markdown renderers, and rich-text editor output, where a team reaches for dangerouslySetInnerHTML to render formatted content and forgets that "formatted" and "trusted" are not the same property. A security review of any React codebase should grep for every occurrence of this prop first, before looking at anything else, because each one is a confirmed HTML injection point until proven otherwise.
What actually sanitizes HTML before it reaches dangerouslySetInnerHTML?
DOMPurify is the sanitizer that recent 2026 React security guides — including writeups from cyberphinix, AppSecMaster, and OneUptime — consistently converge on as the standard pairing for dangerouslySetInnerHTML, because it parses the HTML string into a DOM tree, strips scriptable elements and event-handler attributes, and serializes the result back to a safe string rather than relying on regex-based blocklists that attackers routinely bypass with encoding tricks. The correct pattern is dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }} — sanitizing at the point of render, not at the point of storage, since a sanitizer's allowlist can change between when content was saved and when it's displayed. DOMPurify also supports configuration profiles that restrict allowed tags and attributes further than its defaults, which matters for high-risk surfaces like user comments where even <img onerror=...> style payloads need to be foreclosed rather than merely stripped once.
What does the Trusted Types API add on top of a sanitizer?
The Trusted Types browser API, which reached Baseline support across Chrome, Safari, and Firefox by early 2026, does not sanitize anything itself — it enforces that dangerous DOM sinks like innerHTML can only accept a TrustedHTML object, not a raw string, which means a developer literally cannot bypass sanitization by accident once a Trusted Types policy is enforced via Content-Security-Policy. This complements DOMPurify rather than replacing it: DOMPurify (which has native Trusted Types output support) does the cleaning, and the browser-level policy guarantees that no code path — including a future contributor who doesn't know the sanitization rule — can slip an unsanitized string past the check. For React apps handling untrusted HTML at any real scale, pairing a require-trusted-types-for 'script' CSP directive with a DOMPurify-backed policy turns a code-review convention into a runtime-enforced guarantee.
What did the 2025 Shai-Hulud npm worm reveal about React's dependency risk?
Shai-Hulud showed that a React app's attack surface extends through every transitive package in its node_modules tree, not just the code a team wrote. CISA issued an alert on September 23, 2025 confirming a self-propagating worm had compromised more than 500 npm packages, and Palo Alto Networks' Unit 42 documented how it harvested GitHub personal access tokens and cloud credentials from developer and CI environments, then used stolen tokens to automatically publish trojanized versions of further packages — a self-replicating supply-chain infection rather than a single poisoned release. A second wave, tracked as Shai-Hulud 2.0, appeared in November 2025 and shifted execution earlier, running malicious code during package pre-install rather than post-install, evading defenses tuned to the first wave's behavior. Neither wave required a victim to write insecure code; a package.json pulling in an affected dependency, directly or transitively, was enough.
How Safeguard Helps
Safeguard's dependency scanning resolves the full transitive tree up to 100 levels deep across npm, pnpm, and Yarn — roughly 40 levels beyond what most SCA tools reach — which matters directly for worms like Shai-Hulud that propagate through indirect dependencies a shallow scanner never inspects. Deep scanning cross-references every resolved package against known typosquat patterns and internal versus public registries, surfacing dependency-confusion findings before a build ever pulls the wrong package automatically. It also produces an unused-dependency signal, flagging packages present in the tree but never imported, so teams can shrink the attack surface a future supply-chain incident could exploit without touching a line of application code. None of that replaces the discipline of sanitizing dangerouslySetInnerHTML output with DOMPurify or allowlisting URL schemes on href — but it closes the half of React app risk that lives outside the code your team actually writes.