Let me state the conclusion first. Because React HTML-escapes values embedded in JSX by default, as long as you write it straightforwardly it's strong against XSS. The problem is that "escape hatches" that disable that defense are prepared in the framework itself, and moreover the one who opens them is not the attacker but the developer. The representative holes are four — dangerouslySetInnerHTML, the javascript: scheme in href / src, direct DOM manipulation via ref, and embedding third-party HTML.
This isn't a story of "React is dangerous." On the contrary, React's default falls to the safe side. What's dangerous is the moment you remove that safety valve because "I want it to work on my machine." This article maps out where XSS (reflected, stored, DOM-XSS) opens in Next.js / React, and systematizes — with vulnerable→fixed real code — output-time sanitization, URL-scheme verification, Trusted Types, and CSP (nonce) to make the damage small even if <script> is planted. XSS is one of the most frequent classes included in OWASP Top 10's injection (A03) (OWASP Top 10). The app's overall security map is summarized in the Next.js × Supabase application-security complete guide, and this article is a deep dive narrowed to "XSS / output safety" within it.
1. Premise: React escapes XSS by default
First, accurately understand React's "working defense." A value passed to JSX's {} is rendered as a text node. Since it isn't interpreted as HTML, tags contained in the string are simply displayed on screen as-is.
// React は {} の中身を「テキスト」として描画する=HTMLとして解釈しない
function Comment({ body }: { body: string }) {
// body が "<script>alert(1)</script>" でも、画面には文字列として出るだけ
return <p>{body}</p>;
}
Attribute values are escaped the same way. So if it's just "putting user input on screen as-is," XSS doesn't occur. Nevertheless, XSS occurs only when the developer intentionally removes this default. Let me list those exits.
| Escape hatch | What happens | Main XSS class |
|---|---|---|
dangerouslySetInnerHTML | Interprets the string as HTML and inserts it | stored / reflected |
javascript: in href / src | A script may run on click/load | reflected / DOM |
el.innerHTML = ... via ref | Directly manipulates the DOM outside React | DOM |
| Embedding third-party HTML | "Trusting" the CMS, ads, email body | stored |
What's common is that all of them "pour a string into a context where it's executed as HTML or code." Conversely, if you just hold this down, the majority of the defense is decided.
2. The three classes of XSS and how they appear in Next.js
XSS divides into three by "where the attack code passes through." In Next.js, whether it occurs on the server (RSC / SSR) or the client (after hydration) directly ties to the countermeasure.
| Type | Route | Typical in Next.js |
|---|---|---|
| Reflected | The request value is reflected into the response immediately | SSR-rendering searchParams with dangerouslySetInnerHTML |
| Stored | Tainted HTML saved in a DB, etc., is rendered later | Save a post body / profile HTML → display |
| DOM-XSS | source→sink all completes inside the browser | Writing location.hash back into innerHTML |
What's important here is DOM-XSS. Reflected and stored go through the server, so traces can remain in server logs or a WAF. On the other hand, DOM-XSS's attack origin is often a value not sent to the server, like a URL fragment (#...), so it's completely invisible from the server. React's default escaping also doesn't work, since you directly hit a raw browser DOM API like innerHTML. The reason the idea of "protect everything with server-side sanitization" doesn't apply to DOM-XSS is this.
3. Escape hatch ①: dangerouslySetInnerHTML
The name is the warning itself. This is an API that "sets innerHTML via React," and the string you pass is interpreted as HTML. The problem is that the API doesn't confirm at all whether the string's origin is trustworthy.
// 脆弱:APIから受け取ったHTMLをそのまま挿入(格納型XSS)
function Article({ id }: { id: string }) {
const [html, setHtml] = useState("");
useEffect(() => {
fetch(`/api/articles/${id}`)
.then((r) => r.json())
.then((d) => setHtml(d.bodyHtml)); // ← DB由来の汚染HTML
}, [id]);
// bodyHtml に <img src=x onerror=...> が混ざっていれば実行される
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
There's a browser spec that a <script> tag isn't executed after insertion, but event-handler attributes like onerror are executed. In other words, "just reject script tags" can't plug it.
There's only one judgment criterion — "is this HTML from an origin where an attacker can't be involved by even one bit?" A completely trustworthy output like JSON-LD you assembled yourself on the server is acceptable (this site too uses it only for embedding structured data). But if a value originating from a request, DB, or external API is mixed in, always sanitize at output time (configuration details in section 7).
// 修正:出力の直前にサニタイズする(DOMPurifyの設定は第7節で詳述)
import DOMPurify from "isomorphic-dompurify";
function Article({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
4. Escape hatch ②: the javascript: scheme in href / src
JSX escapes attribute values, but HTML escaping doesn't neutralize URL schemes. That's because a URL starting with javascript: is a syntactically correct URL. If you pass a dynamically-assembled value to href or src, it becomes a route where a script may run on click or load.
// 脆弱:ユーザー入力のURLをそのまま href に渡す
function ProfileLink({ website }: { website: string }) {
// website が "javascript:/* 任意コード */" だと、クリックで実行されうる
return <a href={website}>サイト</a>;
}
Since React 16.9, a warning is shown in development for javascript: URLs, but a warning is not a defense. Whether to trust the value you must decide yourself. The countermeasure is "an allowlist of permitted schemes." Don't write your own regex here. There's obfuscation like java\tscript: that inserts a tab or newline, and a hand-written regex can be slipped past. The standard URL parser removes such control characters according to the spec before interpreting, so scheme determination becomes robust.
// lib/safe-href.ts — 許可スキームだけ通す。URL APIで難読化に強く判定する
const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
export function safeHref(raw: string): string {
try {
// 相対URLも解決できるよう base を渡す
const protocol = new URL(raw, "https://example.com").protocol;
return SAFE_PROTOCOLS.has(protocol) ? raw : "#";
} catch {
return "#"; // パースできない値は通さない
}
}
// 修正:スキームを検証してから渡す
import { safeHref } from "@/lib/safe-href";
function ProfileLink({ website }: { website: string }) {
return <a href={safeHref(website)} rel="noopener noreferrer">サイト</a>;
}
Note that scheme verification stops javascript: and data:, but "where the link flies to" (luring to an external malicious site) is a separate problem. The danger of trusting a return-destination URL is dealt with as the continuous open redirect in Next.js open-redirect prevention.
5. Escape hatch ③: DOM manipulation via ref and third-party HTML
React's safety holds on the premise of "update the DOM through React." If you take out a raw DOM element with ref and directly assign innerHTML, you break that premise yourself.
// 脆弱:ref で取った要素に innerHTML を直接代入(Reactのエスケープを迂回)
function Banner({ markup }: { markup: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.innerHTML = markup; // 危険シンク
}, [markup]);
return <div ref={ref} />;
}
The principle of the fix is to "stop raw DOM manipulation and return to React's rendering." If what you want to display is text, using textContent means it isn't interpreted as HTML. If HTML is needed, pass it through the sanitization of section 7.
// 修正:テキストなら state 経由で描画(自動エスケープに戻る)
function Banner({ text }: { text: string }) {
return <div>{text}</div>;
}
Third-party HTML is the same hole. A CMS's rich text, an ad tag, an email-body preview — trusting these as "it's external so it's probably already formatted" and flowing them into dangerouslySetInnerHTML is dangerous. A CMS that multiple users can edit is, at that point, not a "trustworthy origin." "Whether it's trustworthy" isn't technology but a design judgment of who can write that data, and this is the area that can't be fully left to a tool.
6. DOM-XSS: sever the flow from source to sink
DOM-XSS occurs on the client side when "a value an attacker can manipulate (source) reaches a dangerous process (sink) without being validated." As in section 2, since it doesn't go through the server, it's worth understanding independently. Let me line up representative sources/sinks.
| Tainted source (client) | Dangerous sink | Result |
|---|---|---|
location.hash / location.search | element.innerHTML | DOM-XSS |
URLSearchParams.get() | document.write() | DOM-XSS |
document.referrer | eval() / new Function() | arbitrary code execution |
event.data of postMessage | location.href = ... | luring / XSS |
value of localStorage | setTimeout(string) | arbitrary code execution |
A common one is the pattern of writing a URL-hash value back to the screen.
// 脆弱:URLハッシュの値をそのままHTMLに書き戻す(DOM-XSS)
useEffect(() => {
const tab = decodeURIComponent(location.hash.slice(1)); // ← 汚染source
document.querySelector("#title")!.innerHTML = `タブ: ${tab}`; // ← 危険sink
}, []);
location.hash (after #) isn't sent to the server. So it appears in neither the SSR HTML nor the server logs, and the WAF can't detect it either. The fix is "don't write directly to the DOM, render via React's state" — with just this, you return under the umbrella of automatic escaping.
// 修正:DOMに直接書かず state 経由で描画する
const [tab, setTab] = useState("");
useEffect(() => {
setTab(decodeURIComponent(location.hash.slice(1)));
}, []);
return <p id="title">タブ: {tab}</p>;
eval / new Function / setTimeout that takes a string are best not used in the first place. If you stop the design of evaluating a config value "as code" and treat it as data, this class of sink disappears.
7. Output-time sanitization: DOMPurify and a "don't put it in at all" design
If you absolutely must render untrusted HTML, neutralize it with DOMPurify. Since Next.js renders on both the server and the client, choosing isomorphic-dompurify (which internally uses a DOM implementation on the server) works in both environments.
// components/rich-text.tsx — 出力の直前にサニタイズし、許可を「狭く」明示する
import DOMPurify from "isomorphic-dompurify";
export function RichText({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "br", "b", "i", "em", "strong", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href"], // 属性は最小限に
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
There are three principles you shouldn't remove in design.
- Sanitize at output time, not input time. The same data may be reused in multiple contexts (HTML body, attribute, URL); improvements to the sanitizer don't retroactively apply to past saved data, and an input filter is easily bypassed by saving via a different route. Applying it at "the place you render" is the most reliable.
- The allowlist is narrow and explicit. Not "exclude the dangerous (denylist)" but "pass only the permitted (allowlist)." Even if a new attack vector is found, if you've narrowed the permissions, you're less affected.
- The strongest countermeasure is "don't hold raw HTML as a string." For user-posted rich text, if you save it not as an HTML string but as Markdown or structured blocks and render it with a safe renderer, there's no HTML string to sanitize in the first place. For example, this blog renders with
react-markdown, but by default it doesn't interpret raw HTML. The moment you addrehype-raw, that safety is lost.
The honest scope. DOMPurify is powerful, but it's not a "magic safety device." If the allowlist doesn't match the rendering context, a hole remains, and if you permit
SVGorMathML, the surface to consider increases. The judgment of what to permit is a design judgment that depends on your product's requirements, and the library can't take it over.
8. Trusted Types and CSP (nonce): the "last wall" of defense-in-depth
So far it's been primary defense to "not open a hole / neutralize." Next is defense-in-depth that suppresses the damage even if it slips past that. The point is to stand on the premise that there's no guarantee the primary defense is perfect.
Trusted Types: bind dangerous sinks with types
Trusted Types is a browser mechanism that prohibits, at runtime, bare-string assignment to a dangerous sink like innerHTML. Enabling it with CSP, you can only pass a TrustedHTML generated by a registered policy to the sink, and the places to audit are consolidated into "a few policies."
// CSP: require-trusted-types-for 'script'; trusted-types app-html;
// 有効化すると innerHTML 等への素の文字列代入が TypeError になる
import DOMPurify from "isomorphic-dompurify";
// 型は @types/trusted-types を導入して補う(any を使わない)
if (typeof window !== "undefined" && "trustedTypes" in window) {
window.trustedTypes!.createPolicy("app-html", {
createHTML: (input: string) => DOMPurify.sanitize(input),
});
}
The specification of require-trusted-types-for 'script' has MDN: Content-Security-Policy as its primary source. However, browser support isn't uniform (Chromium-based leads, others are limited). So this is the "last wall," and you can't premise it on protecting only supported browsers. It's positioned as one sheet of defense-in-depth.
CSP (nonce): don't let it execute even if injected
CSP (Content Security Policy) restricts the origins of scripts a page can load. If you avoid 'unsafe-inline' and issue a nonce per request, making it script-src 'self' 'nonce-...' 'strict-dynamic', then even if an inline script is injected, it isn't executed.
// CSP の考え方(nonce発行とミドルウェア実装の詳細は別記事)
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
].join("; ");
There's an honesty to emphasize here. CSP doesn't prevent the "injection" of XSS but restricts the "execution/abuse" after injection. And a loose CSP that attaches 'unsafe-inline' or widens script-src with a wildcard only breeds complacency and isn't a defense. The strict implementation of nonce issuance and middleware is carved out in Next.js security headers and CSP (nonce).
9. Detection: follow the flow from tainted input to a DOM sink
Once you've decided to plug it with design, verify "is it plugged." Since much of XSS shares a structure, there are parts that can be mechanically detected.
- Surface all
dangerouslySetInnerHTML— fix as a review target whether the output passes through sanitization. - Follow the data flow from tainted source to dangerous sink (taint analysis) — whether a value from
location.*/searchParams/ a request reachesinnerHTML/document.write/evalwithout validation. - Whether the dynamic value of
href/srchas scheme verification. - Where
eval/new Function/ stringsetTimeoutare used.
These can't be accurately written with regex. That's because you need to follow "where the value comes from and where it flows." The OSS Aegis I publish implements this data-flow detection, and it runs with no installation.
# インストール不要・設定不要でスキャン(汚染入力→DOMシンクを可視化)
npx @aegiskit/cli scan
As a manual verification procedure, OWASP's Web Security Testing Guide systematizes the testing viewpoints of XSS (reflected, stored, DOM). The royal road is to reproduce and confirm the scan's "suspicion" in your own app.
The honest scope. What can be detected is only the form "tainted input reaches a dangerous sink," "it doesn't pass through sanitization." "Is that HTML's origin really trustworthy," "does the sanitizer's allowlist match this rendering context," "does the output encoding correctly correspond to the context (HTML body / attribute / URL / JS)" — the validity of these safe-output designs can't be judged by a tool. Data-flow analysis is also intra-function in principle and misses flows that cross modules. Detection isn't a replacement for human review but a complement to it.
10. Pre-production checklist
Whether outsourced or AI-made, confirm at least this before going to production.
- At each
dangerouslySetInnerHTML, the HTML's origin is trustworthy, or it passes through output-time sanitization - The sanitizer is allowlist-style (explicitly states the permitted tags/attributes)
- The dynamic value of
href/srcverifies the scheme with the URL API (doesn't rely on a self-made regex) - You don't use
innerHTMLassignment viarefordocument.write(text goes totextContent/ state) - A tainted source like
location.hash/searchParamsdoesn't pass straight to a DOM sink - You don't use
eval/new Function/setTimeoutthat takes a string - User posts are held as Markdown / structured data, not raw HTML (you don't re-enable raw HTML with
rehype-raw, etc.) - You attach CSP (nonce) and don't loosen it with
'unsafe-inline'or a wildcard - If you have the bandwidth, enable Trusted Types and narrow dangerous sinks to going through a policy
- You permanently station taint/DOM-sink detection in CI (
npx @aegiskit/cli scan)
What's effective from the client's viewpoint is the three questions "How many dangerouslySetInnerHTML are there?" "Who can write that HTML's origin?" "Where do you verify the URL's scheme?" A good developer can answer immediately.
11. How far yourself, and where audit begins
Finally, let me draw the line honestly.
Forgetting to sanitize, the tainted→sink flow, and a javascript: scheme passing straight through can be mechanically detected by static analysis. This is an area where you should let CI stand guard, and humans don't need to eyeball it every time. First visualizing the current state with Aegis (free OSS, npx @aegiskit/cli scan) is the most cost-effective first step.
On the other hand, the validity of safe output design — "is that HTML trustworthy," "does the allowlist match this context" — remains with human judgment. A product where a tool here asserts "it's safe" is rather dangerous. Aegis detects/warns about dangerous output routes, but doesn't prove that the output design is correct. There's no magic to make it completely safe. That's exactly why starting from the first visualization (free OSS, npx @aegiskit/cli scan) to grasp where the holes are is the most cost-effective first step.
That's exactly why a line is needed. How far to firm up yourself, and where an expert's review is needed — if you need a review of an existing app's XSS / output safety, or the design of defense-in-depth including CSP / Trusted Types, I take it on with a security audit. I myself, in the lumber-distribution-industry DX project, designed and verified the output safety of data crossing the trust boundary in actual operation.
Frequently asked questions (FAQ)
Q. If I use React, won't XSS occur?
A. As long as you write it straightforwardly, React's automatic escaping prevents the majority. But dangerouslySetInnerHTML, javascript: in href, innerHTML via ref, and DOM-XSS are "outside" that defense. The framework's safety holds only as long as you don't remove the safety valve yourself.
Q. Must I not use dangerouslySetInnerHTML?
A. It's not prohibited. Limited to HTML whose origin is completely trustworthy (JSON-LD you assembled yourself on the server, etc.), it's an appropriate use. If a value from a request, DB, or external is mixed in, sanitize with DOMPurify at output time.
Q. Is it enough to sanitize at input time? A. It's insufficient. The same data may be reused in a different context, an improvement to the sanitizer may not retroactively apply to past data, or the input filter may be bypassed by saving via a different route. Applying sanitization at the output time you render is the most reliable.
Q. If I put in CSP, can I prevent XSS?
A. CSP isn't something that "prevents injection" but defense-in-depth that "restricts the execution/abuse after injection." Moreover, it loses its effect if loosened with 'unsafe-inline', etc. Think of it as the "last wall" combined with primary defense (sanitization, scheme verification).
Q. Can DOM-XSS be prevented with server-side countermeasures?
A. It can't. A value not sent to the server, like location.hash, is often the origin, and neither SSR sanitization nor a WAF reaches it. You need a design that doesn't pass a tainted source to a dangerous sink on the client (via state, textContent).
Summary: the defensive boundary moves from "whether there's escaping" to "the correctness of the output design"
Let me organize the key points.
- React is strong against XSS because it automatically HTML-escapes JSX expression interpolation. The hole is opened not by the attacker but by the developer — the four routes of
dangerouslySetInnerHTML, thejavascript:scheme,innerHTMLviaref, and third-party HTML. - DOM-XSS completes inside the browser without going through the server, and is hard to see in either server logs or a WAF. Sever the tainted-source→dangerous-sink flow via state and
textContent. - Untrusted HTML gets allowlist sanitization with DOMPurify at output time. The most robust is a design that doesn't hold a raw HTML string (Markdown, structured data).
- Trusted Types / CSP (nonce) are defense-in-depth that suppress the damage even if it gets mixed in. A loose CSP only breeds complacency and isn't a defense.
- Forgetting to sanitize and the tainted→sink flow can be mechanically detected. But the validity of safe output design can only be protected by design and human review.
Building fast with AI is itself correct. Firming up what you built fast, safely, without leaks — if you need to build that mechanism, or a review of an existing Next.js / React app's output safety, please feel free to consult me.