# XSS / DOM-XSS prevention in Next.js / React — the holes of dangerouslySetInnerHTML, and safe sanitization / CSP

> React escapes JSX by default, but it slips out via dangerouslySetInnerHTML, href=javascript:, DOM manipulation through ref, and DOM sinks. It explains, with vulnerable→fixed real code, where XSS/DOM-XSS occurs, output-time sanitization with DOMPurify, and the defense-in-depth of CSP (nonce) / Trusted Types.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Next.js, React, セキュリティ, TypeScript
- URL: https://tomodahinata.com/en/blog/nextjs-xss-dom-xss-dangerouslysetinnerhtml-prevention-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- React is strong against XSS because it automatically HTML-escapes JSX expression interpolation. But the hole is 'the designer opens it themselves' — the four routes of dangerouslySetInnerHTML, the javascript: scheme in href/src, innerHTML via ref, and third-party HTML.
- DOM-XSS completes inside the browser without going through the server. It occurs when a value flows, without validation, from a tainted source like location.hash to a dangerous sink like innerHTML/eval/document.write, and is hard to see in either server logs or a WAF.
- For untrusted HTML, 'sanitize at output time with DOMPurify' is the basic. The most robust is a design that 'doesn't hold raw HTML as a string in the first place.' For URLs, verify the scheme with the URL API, not a regex.
- Trusted Types and CSP (nonce) are defense-in-depth — not preventing XSS from getting in, but the 'last wall' that doesn't let it execute/be abused even if it gets in. A CSP with loose settings only breeds complacency and isn't a defense.
- Forgetting to sanitize and the tainted→sink flow can be mechanically detected by static analysis. But safe output design — 'is that HTML really trustworthy,' 'does the sanitizer's allowlist match the context' — can only be protected by code review.

---

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](https://owasp.org/www-project-top-ten/)). The app's overall security map is summarized in the [Next.js × Supabase application-security complete guide](/blog/nextjs-supabase-application-security-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.

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

```tsx
// 脆弱：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).

```tsx
// 修正：出力の直前にサニタイズする（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.

```tsx
// 脆弱：ユーザー入力の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.

```ts
// 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 "#"; // パースできない値は通さない
  }
}
```

```tsx
// 修正：スキームを検証してから渡す
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](/blog/nextjs-open-redirect-callback-url-prevention-guide).

---

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

```tsx
// 脆弱：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.

```tsx
// 修正：テキストなら 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.

```tsx
// 脆弱：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.

```tsx
// 修正：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.

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

1. **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.
2. **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.
3. **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 add `rehype-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 `SVG` or `MathML`, 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."

```ts
// 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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.

```ts
// 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)](/blog/nextjs-security-headers-csp-nonce-middleware-guide).

---

## 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 reaches `innerHTML` / `document.write` / `eval` without validation.
- Whether the **dynamic value of `href` / `src`** has scheme verification.
- Where **`eval` / `new Function` / string `setTimeout`** are 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](/aegis) I publish implements this data-flow detection, and it runs with no installation.

```bash
# インストール不要・設定不要でスキャン（汚染入力→DOMシンクを可視化）
npx @aegiskit/cli scan
```

As a manual verification procedure, OWASP's [Web Security Testing Guide](https://owasp.org/www-project-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` / `src` **verifies the scheme with the URL API** (doesn't rely on a self-made regex)
- [ ] You don't use `innerHTML` assignment via `ref` or `document.write` (text goes to `textContent` / state)
- [ ] A **tainted source** like `location.hash` / `searchParams` doesn't pass straight to a DOM sink
- [ ] You don't use `eval` / `new Function` / `setTimeout` that 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](/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](/aegis/audit). I myself, in the [lumber-distribution-industry DX project](/case-studies/lumber-industry-dx), 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`, the `javascript:` scheme, `innerHTML` via `ref`, 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.

---

## References

- [OWASP Top 10 (XSS is classified under A03:2021 Injection)](https://owasp.org/www-project-top-ten/)
- [MDN — Content-Security-Policy (the primary source for CSP and require-trusted-types-for)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
- [OWASP Web Security Testing Guide (testing viewpoints for reflected, stored, and DOM-XSS)](https://owasp.org/www-project-web-security-testing-guide/)
