XSS, Injection, and Unsafe HTML
Understand how cross-site scripting and injection attacks work against React applications, why React's default rendering protects you, where that protection breaks down, and how to defend the remaining gaps with sanitization and Content-Security-Policy.
React earns a reputation as a 'safe by default' framework because JSX escapes text content automatically. But that safety is a property of one specific code path, not a guarantee about your whole application. The moment you reach for dangerouslySetInnerHTML, build a URL from user input, inject a script tag manually, or trust data coming back from an API that itself echoes user content, you can reopen the exact vulnerability class React was designed to close. This lesson builds a mental model of cross-site scripting (XSS) from first principles, shows exactly where React's escaping applies and where it does not, and gives you concrete, testable defenses.
What XSS actually is
Cross-site scripting is not a bug in a specific site; it is a consequence of a browser executing whatever JavaScript it finds embedded in a page it renders, regardless of where that JavaScript came from. An XSS vulnerability exists whenever attacker-controlled data ends up somewhere the browser will parse as executable code or as markup that can carry executable code — inside a <script> tag, an onerror/onclick attribute, an href using the javascript: scheme, or raw HTML that a script later inserts into the DOM.
The attacker's goal is to run JavaScript in the victim's authenticated browser session. Once that happens, the attacker's script has full access to everything your app's own JavaScript has: it can read the DOM, steal data from React state via closures if it can reach them, make authenticated fetch() calls using the user's cookies or in-memory tokens, redirect the page, or install a persistent keylogger on input fields. XSS is dangerous precisely because it does not need to break authentication — it inherits it.
Security researchers usually split XSS into three families, and it helps to know which one you are defending against because the fix differs: stored XSS (the payload is saved on the server, e.g. in a comment, and served to every later visitor), reflected XSS (the payload arrives in the current request, e.g. a search query in the URL, and is echoed straight back into the response), and DOM-based XSS (the payload never touches the server at all — client-side JavaScript reads something attacker-controlled, like location.hash or document.referrer, and writes it into the DOM in an unsafe way).
• Stored XSS: payload persists in a database and executes for every viewer — highest blast radius. • Reflected XSS: payload lives in the request (query string, form field) and is echoed back once. • DOM-based XSS: the vulnerable code path is entirely client-side — no server round-trip is needed. • All three share the same root cause: untrusted data reaches a sink that the browser executes.
Why JSX text is safe by default
When you write <p>{comment.text}</p>, React does not concatenate comment.text into an HTML string. It creates a DOM text node and sets its textContent-equivalent, or on the string-rendering path it HTML-entity-encodes the value (turning < into <, & into &, and so on) before it becomes part of the markup. A string like "<img src=x onerror=alert(1)>" placed into JSX as {value} is rendered as literal, inert text on the page — the browser never parses it as a tag.
This is why the single most important React security habit is also the easiest: let JSX render your data as children or as normal attribute values, and do not manually build HTML strings from user input. The vulnerability only reappears when you deliberately opt out of this behavior.
• {value} as element children → escaped, rendered as text. Safe. • <input value={value} /> and most string attributes → escaped. Safe. • dangerouslySetInnerHTML={{ __html: value }} → raw HTML, parsed by the browser. Not safe with untrusted input. • <a href={value}> where value can be 'javascript:alert(1)' → the browser will execute it on click. Not safe without validation.
Injection sinks that survive React's escaping
React's escaping protects the text-content and most-attribute code paths, but several sinks bypass it entirely because they are not text at all — they are markup, URLs, or code that the browser interprets. Knowing this list by heart is the core skill of frontend security review.
The href/src javascript: scheme is a classic and still-common vector: a browser will happily execute javascript:alert(document.cookie) if it is set as an anchor's href and the user clicks it, or in older browsers, on hover. Any place your app takes a URL from user input — a profile 'website' field, a redirect parameter, a rich-text editor's link — and puts it directly into href or src needs an explicit protocol allowlist (http:, https:, mailto: only) before rendering.
Attribute-injection sinks like dangerouslySetInnerHTML, iframe srcDoc, and any use of the DOM APIs innerHTML, outerHTML, insertAdjacentHTML, or document.write inside a useEffect all parse a string as HTML. If that string contains attacker data, script tags and event-handler attributes inside it will execute.
Finally, watch for indirect injection through third-party widgets: a chat widget, an ad script, or a analytics snippet that itself calls eval() or innerHTML on data it fetches is a supply-chain XSS vector even though your own React code never touches dangerouslySetInnerHTML.
• href={userUrl} / src={userUrl} with a javascript: or data: scheme. • dangerouslySetInnerHTML with unsanitized HTML. • Direct DOM manipulation in useEffect/useRef: el.innerHTML = value. • Template-string-built HTML passed to any of the above. • SVG content rendered as HTML — <svg> can carry <script> and onload handlers too.
Sanitization: turning untrusted HTML into safe HTML
Sometimes you genuinely need to render HTML that came from a user — a markdown-to-HTML rich text editor is the canonical case. The correct fix is not to avoid HTML entirely; it is to sanitize it with a well-tested allowlist-based library, most commonly DOMPurify, immediately before it reaches dangerouslySetInnerHTML, and never to trust hand-rolled regex stripping.
A sanitizer works by parsing the HTML into a DOM tree and then walking it, keeping only tags and attributes present on an allowlist (e.g. <b>, <i>, <a href>, <p>) and stripping everything else — script tags, on* event handler attributes, style attributes that could carry expression() in old IE, and dangerous URL schemes inside href/src. Sanitize as close to the render call as possible, not once at write-time in the database, because sanitizer rules evolve and stored-then-trusted content ages badly.
Regex-based 'sanitization' (stripping <script> tags with a string.replace) is not a defense. Browsers parse malformed and obfuscated HTML far more permissively than a hand-written regex can anticipate — <img src=x onerror=alert(1)> contains no <script> tag at all and slips straight past a script-tag filter.
Content-Security-Policy as defense in depth
Even with careful escaping and sanitization, CSP is the safety net that limits what an XSS payload can actually do if one slips through. CSP is an HTTP response header (or a <meta> tag) that tells the browser which sources of scripts, styles, images, and connections are allowed to load or execute on the page — everything else is refused, even if it is present in the HTML.
A strong baseline policy sets script-src to 'self' plus any specific CDN origins you actually use, and — critically — omits 'unsafe-inline' so that inline <script> tags and inline event-handler attributes (onclick="...") simply will not run, regardless of how they got into the page. Because this also blocks legitimate inline scripts, the modern approach is a per-request cryptographic nonce (script-src 'nonce-<random>') issued by your server and attached to the script tags you actually intend to run.
CSP will not stop every attack — it does not sanitize your data — but it converts many otherwise-critical XSS bugs into no-ops, because the injected <script> tag is present in the DOM but the browser refuses to execute it. Treat it as a mandatory second layer, not a replacement for correct escaping.
• default-src 'self' — deny everything not explicitly allowed. • script-src 'self' 'nonce-<value>' — block inline scripts unless nonce-tagged by the server. • object-src 'none' — block <object>/<embed>, common legacy plugin-based XSS vectors. • frame-ancestors 'self' — the CSP-based replacement for X-Frame-Options; also prevents clickjacking (see next module).
Clickjacking: hijacking real clicks instead of running code
Clickjacking is a distinct but related attack: instead of injecting script, the attacker embeds your real page in an invisible <iframe> on their own malicious site, layers deceptive UI on top of it, and tricks the user into clicking what looks like a harmless button but is actually your app's 'Transfer funds' or 'Delete account' button underneath, at pixel-perfect coordinates.
The defense is server-side, not something React code can fix: send the frame-ancestors CSP directive (frame-ancestors 'self' or frame-ancestors 'none') or, for older browser support, the X-Frame-Options: DENY / SAMEORIGIN header, so the browser refuses to render your page inside a foreign iframe at all.
Sanitizing HTML before dangerouslySetInnerHTML
Sanitize immediately before rendering, using an explicit allowlist of tags and attributes. Never trust content just because it was previously sanitized and stored — sanitize again at render time. DOMPurify still parses href values, but pair it with your own protocol allowlist for links (see the mistakes table).
import DOMPurify from "dompurify";
interface RichTextProps {
html: string; // untrusted: came from a user-authored markdown/HTML field
}
function RichText({ html }: RichTextProps) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li", "br"],
ALLOWED_ATTR: ["href", "title"],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Code Examples
Because comment.body is passed as JSX children rather than concatenated into an HTML string, React renders it as a text node. Even a malicious payload is displayed literally as visible characters on the page instead of being parsed as a tag. Expected result: Renders literally: <script>alert(1)</script> as visible text, not an executed script.
This never touches the server — the URL query string is read directly in the browser and written into innerHTML. It is a textbook DOM-based XSS: no dangerouslySetInnerHTML in sight, yet the same class of bug.
Common mistake: Using innerHTML (or insertAdjacentHTML/document.write) in a ref-based effect is functionally identical to dangerouslySetInnerHTML — React's escaping does not apply because you bypassed React's rendering entirely.
Returning to JSX for the interpolation removes the sink entirely. If the banner genuinely needed to support bold/italic formatting from the query string, the fix would be DOMPurify.sanitize(name) before a dangerouslySetInnerHTML, not a hand-rolled string filter.
Parsing with the URL constructor and checking url.protocol rejects javascript:, data:, and vbscript: schemes reliably, which a naive startsWith('http') check can miss (e.g. ' javascript:alert(1)' with a leading space or tab bypasses some naive checks but not URL parsing).
url.protocol === "http:" || url.protocol === "https:"
interface Comment {
id: string;
author: string;
body: string; // may contain "<script>alert(1)</script>" from an attacker
}
function CommentItem({ comment }: { comment: Comment }) {
// React escapes comment.body automatically: it becomes inert text,
// not parsed markup. No sanitizer needed for plain text.
return (
<article>
<strong>{comment.author}</strong>
<p>{comment.body}</p>
</article>
);
}// VULNERABLE: reads an untrusted client-side source and writes raw HTML
function WelcomeBanner() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const name = new URLSearchParams(location.search).get("name") ?? "";
if (ref.current) {
// location.search is attacker-controlled (the victim just has to
// click a crafted link) and innerHTML parses it as HTML.
ref.current.innerHTML = `Welcome, ${name}!`;
}
}, []);
return <div ref={ref} />;
}
// Attacker link:
// https://app.example.com/?name=<img src=x onerror=alert(document.cookie)>function WelcomeBanner() {
const [name] = useState(
() => new URLSearchParams(location.search).get("name") ?? ""
);
// Plain text: no HTML parsing risk at all.
return <div>Welcome, {name}!</div>;
}function isSafeUrl(value: string): boolean {
try {
const url = new URL(value, window.location.origin);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
function ProfileLink({ website }: { website: string }) {
if (!isSafeUrl(website)) {
return <span>{website}</span>; // show as inert text, don't link it
}
return (
<a href={website} target="_blank" rel="noopener noreferrer">
{website}
</a>
);
}Where React's escaping applies vs. where it doesn't
Escaping is a property of the sink you write to, not a property of React as a whole.
Untrusted data
|
v
+-------------------------+ +----------------------------+
| JSX children / props | | dangerouslySetInnerHTML, |
| {value}, <input value> | | innerHTML, href/src schemes |
+-------------------------+ +----------------------------+
| |
v v
React HTML-entity-encodes it Browser parses it as real
before it becomes markup HTML / executes script: URLs
| |
v v
SAFE (inert text) UNSAFE unless sanitized / validatedImportant Rules
Prefer rendering user data as JSX children/props; never build HTML strings from untrusted input yourself.
Treat dangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, and document.write as equally dangerous sinks.
Sanitize HTML with a maintained allowlist library (e.g. DOMPurify) at render time, never with hand-written regex.
Validate any user-supplied URL's protocol before using it in href/src — allow only http:, https:, mailto:.
Ship a Content-Security-Policy without 'unsafe-inline' as defense in depth, even when your escaping is correct.
Set frame-ancestors (or X-Frame-Options) to prevent clickjacking of authenticated pages.
Why this matters beyond the demo app
Stored XSS in a comments or support-ticket feature has repeatedly been the entry point for full account takeover in production incidents, because the payload runs in the browser of every admin who views the offending content — including admins with elevated privileges the original attacker never had. A single unsanitized rich-text field reachable by low-privilege users is often the highest-value bug in an otherwise well-secured app precisely because it inherits the session of whoever views it, including support staff and moderators.
el.innerHTML = <b>${userInput}</b>;
<b>{userInput}</b> // JSX escapes it, or DOMPurify.sanitize(...) if raw HTML is required
Template-literal HTML built from user input and assigned to innerHTML is a raw-HTML sink identical in risk to dangerouslySetInnerHTML — React's automatic escaping never runs.
<a href={userSuppliedLink}>Visit</a> <a href={isSafeUrl(userSuppliedLink) ? userSuppliedLink: "#"}>Visit</a>
An unvalidated href accepts javascript: URLs, which execute arbitrary script when the link is clicked, entirely bypassing React's text-escaping (href is not HTML content, it's a URL sink).
const clean = html.replace(/<script.*?>.*?<\/script>/gi, '');
const clean = DOMPurify.sanitize(html, { ALLOWED_TAGS: [...] });
Regex-based stripping is trivially bypassed by malformed or obfuscated markup (e.g. event-handler attributes like onerror, or nested/broken tags) that a real HTML parser-based sanitizer correctly neutralizes.
Why is <p>{comment.body}</p> safe even if comment.body contains "<script>alert(1)</script>"? A. React deletes any string that looks like HTML before rendering B. React HTML-entity-encodes the value and renders it as an inert text node, not parsed markup C. Browsers refuse to execute script tags inside <p> elements D. React runs the string through DOMPurify automatically
B. React HTML-entity-encodes the value and renders it as an inert text node, not parsed markup — JSX children/props are escaped to entities and inserted as text, so the browser never parses the string as HTML in the first place — there is no sanitizer involved, just escaping.
Which of these is a DOM-based XSS vector even though it never involves the server? A. Rendering {comment.body} as JSX children B. Reading location.hash in a useEffect and assigning it to element.innerHTML C. Passing a string prop through several child components before rendering it as text D. Using useState to store a comment's text
B. Reading location.hash in a useEffect and assigning it to element.innerHTML — DOM-based XSS occurs entirely client-side when a client-controlled source (like location.hash) flows into a DOM sink like innerHTML without ever round-tripping through the server.
What is the main weakness of sanitizing HTML with a hand-written regex that strips <script> tags? A. Regex is too slow for production use B. It only runs on the server, not the client C. Malformed or attribute-based payloads (e.g. <img onerror=...>) don't contain a <script> tag and slip through D. It breaks TypeScript's type checker
C. Malformed or attribute-based payloads (e.g. <img onerror=...>) don't contain a <script> tag and slip through — Browsers parse HTML far more permissively than a regex can anticipate; event-handler attributes and broken markup execute without ever using a <script> tag, so a script-tag filter provides false confidence.
What does a Content-Security-Policy without 'unsafe-inline' in script-src accomplish? A. It prevents all network requests from the page B. It sanitizes any HTML rendered with dangerouslySetInnerHTML automatically C. It stops inline <script> tags and inline event-handler attributes from executing, even if they get injected into the DOM D. It replaces the need for input validation entirely
C. It stops inline <script> tags and inline event-handler attributes from executing, even if they get injected into the DOM — CSP is a browser-enforced allowlist for script sources; without 'unsafe-inline' the browser refuses to execute inline scripts and inline handlers regardless of how they reached the DOM, limiting the impact of any XSS that slips past your escaping/sanitization.
You inherit a React component that renders a user's 'bio' markdown field using a markdown-to-HTML converter and dangerouslySetInnerHTML, with no sanitization step. Rewrite it to be safe while still allowing bold, italic, links, and paragraphs.
const rawHtml = markdownToHtml(bio); const clean = DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: ['b','i','strong','em','a','p'], ALLOWED_ATTR: ['href'] }); return <div dangerouslySetInnerHTML={{ __html: clean }} />;
Convert markdown to HTML first (as the app already does), then pass that HTML string through DOMPurify.sanitize with an explicit ALLOWED_TAGS/ALLOWED_ATTR list before the dangerouslySetInnerHTML call. Separately validate any href values inside the sanitized output if you need scheme restrictions beyond DOMPurify's defaults.