Next.js App Router で RSC ペイロードから PII 漏洩を防ぐ — 実例から学ぶ Server Component の認可設計
2026-08-15、ClearNets の teen-earn (sukikatsu) 管理画面で「未ログインで /admin にアクセスしても HTML は空だが RSC ペイロードに全ユーザーの email / role / PII が含まれている」致命的な認可バグを発見しました。原因は Next.js App Router の layout.tsx で `if (!admin) return null` にする典型パターンが、子ページの Server Component 実行を止められないこと。本記事は (1) App Router の Server Component / RSC ペイロードの仕組み、(2) curl で 30 秒で再現できる漏洩実証、(3) 正しい認可設計 3 パターン (page 冒頭 getSession / middleware / Next.js 15 forbidden())、(4) layout.tsx で認可する限界、(5) teen-earn の実修正 (B571, commit f31e9d4 + 6043085) を Neon Postgres + Auth.js 前提の実装コード付きで整理し、(6) Playwright + curl での検証方法、(7) FAQ 10 問までを扱います。App Router を本番運用する全開発者に必読の内容です。
1. 導入 — Next.js App Router と RSC ペイロードの前提
Next.js 13 で導入され、14 / 15 で成熟した App Router は、React Server Components (RSC) を基盤に「サーバでコンポーネントを実行 → 結果を ブラウザに送る」パイプラインを提供します。従来の Pages Router (getServerSideProps / getStaticProps) は「Server で HTML を生成 → HTML を送る」でしたが、App Router は「Server Component の実行結果 (React 要素ツリー) を独自のシリアライズ形式 (RSC ペイロード) で 送る」設計です。
RSC ペイロードには以下が含まれます:
- Server Component の JSX 出力: props とその値、 children、Suspense 境界を表現するシリアライズデータ。
- props に渡された生データ: fetch した DB レコード、 user オブジェクト、settings など、そのまま埋め込まれる。
- Client Component への参照: `use client` 境界の 位置と props (Client Component の JS モジュール ID)。
重要なのは「Server Component の実行結果は全て RSC ペイロードに 含まれる」ということです。ここに認可漏れの落とし穴があります。 Server Component が実行されれば、その props / return value は 必ず RSC ペイロードに埋め込まれ、ブラウザに送信されます。 「HTML 表示を止める」だけでは、データの送信は止められません。
2. よくある認可パターンの落とし穴 — layout.tsx の `return null`
App Router で認可を実装しようとして最も自然に見える (かつ最も 致命的な) パターンが以下です。
// app/admin/layout.tsx (バグあり)
import { auth } from '@/lib/auth';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user || session.user.role !== 'admin') {
return null; // ❌ これでは子ページの Server Component の実行を止められない
}
return (
<div className="admin-shell">
<AdminNav />
{children}
</div>
);
}
// app/admin/users/page.tsx (子ページ)
import { db } from '@/lib/db';
export default async function UsersPage() {
// 🔴 未認証でも実行される。全ユーザー PII が RSC payload に流れる
const users = await db.select().from(usersTable);
return (
<div>
{users.map((u) => (
<div key={u.id}>{u.email} — {u.role}</div>
))}
</div>
);
}なぜこれが動かないのか。React の render サイクルとして、App Router は以下のように動きます:
- リクエストが来る (未ログインで /admin/users)
- layout.tsx と page.tsx が並列で実行される (Next.js の parallel rendering)
- layout.tsx は auth() を呼んで null を返す
- page.tsx は auth() を呼ばずに DB から全ユーザーを取得
- React は 「layout が null → 子は描画しない」と判断
- しかし page.tsx の Server Component は既に実行済で、 その return value (全ユーザー DB レコード) が RSC ペイロードに 直列化される
- ブラウザは RSC ペイロードを受信、React は layout=null を見て 何も描画しない → 視覚的には空ページ
- 攻撃者は Network タブの `?_rsc=` レスポンス or curl 直接で PII を取得できる
この誤解の根本原因は、「React Client Component の `{show && <Foo />}` と Server Component の意味論を混同している」ことです。Client Component では props が false なら Foo は render されませんが、Server Component では layout.tsx の return 値と page.tsx の実行は独立です。
3. curl 実証 — 30 秒で PII 漏洩を再現する
バグの実在性を confirmed するために curl で直接叩きます。 teen-earn の実際のケースを模した再現手順です。
# 1. 未認証で通常アクセス (HTML のみ)
$ curl -s https://your-app.com/admin/users | head -100
<!DOCTYPE html>
<html lang="ja">
<head>...</head>
<body class="__variable_...">
<script src="/_next/static/chunks/webpack.js" defer></script>
<!-- ...空の body、視覚的には何も見えない... -->
</body>
</html>
# 2. RSC ヘッダ付きで直接 payload 取得 (漏洩を確認)
$ curl -s -H 'RSC: 1' -H 'Next-Router-State-Tree: %5B%22%22%2C%7B%7D%5D' \
https://your-app.com/admin/users
0:["$","html",null,{"lang":"ja","children":[...]}]
1:I["(app-pages-browser)/./node_modules/next/...", ...]
2:["$","div",null,{"className":"admin-shell","children":[
["$","div",null,{"children":"user1@example.com — admin"}],
["$","div",null,{"children":"user2@example.com — user"}],
...
["$","div",null,{"children":"user9999@example.com — user"}]
]}]
# 🔴 全ユーザーの email と role が RSC payload に平文で入っている
# ブラウザで JS が動く時は layout=null を見て非表示にしているだけこの curl で「認証状態を偽装せずに」全 PII が取得できる状態は、 深刻度 Critical のセキュリティバグです。実際の teen-earn では、 (1) users テーブルの全 email、(2) 未成年 flag、(3) role、(4) 認証 済 provider、が漏れていました。個人情報保護法違反の疑いも生じる レベルです。
4. 正しい設計パターン A — 各 page.tsx 冒頭で認可
最もシンプルで確実なパターンは、認可が必要な page.tsx の冒頭で 必ず session を検証し、権限がなければ throw or redirect することです。
// app/admin/users/page.tsx (修正済)
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export default async function UsersPage() {
// ✅ 必ず session 検証を DB access より先に
const session = await auth();
if (!session?.user) {
redirect('/login');
}
if (session.user.role !== 'admin') {
// Next.js 15.1+ なら forbidden() が理想
throw new Error('Forbidden');
}
// ここに来る = 認可済み
const users = await db.select().from(usersTable);
return (
<div>
{users.map((u) => (
<div key={u.id}>{u.email} — {u.role}</div>
))}
</div>
);
}このパターンの利点は (1) 認可ロジックが page.tsx の中で完結する ので追跡しやすい、(2) throw or redirect で Server Component 実行が 止まる → RSC payload に PII が入らない、(3) auth() は React.cache で自動 memoize されるので複数箇所で呼んでもコストは 1 回のみ、の 3 点です。
再利用性を高めるなら共通ヘルパを作ります。
// lib/auth-helpers.ts
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export async function getAdminSession() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
if (session.user.role !== 'admin') {
// Next.js 15.1+: forbidden() を throw
// それ以前: throw new Error('Forbidden') で 500
const { forbidden } = await import('next/navigation');
forbidden();
}
return session;
}
// app/admin/users/page.tsx
import { getAdminSession } from '@/lib/auth-helpers';
export default async function UsersPage() {
const session = await getAdminSession(); // ✅ 1 行で認可完了
const users = await db.select().from(usersTable);
return <UserList users={users} />;
}5. 正しい設計パターン B — middleware で 403 応答
middleware は全リクエストの最初に走るので、そこで認可失敗を 判定して 403 応答を返せば、Server Component は実行されず RSC payload も生成されません。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
export async function middleware(request: NextRequest) {
// /admin 配下は認可必須
if (request.nextUrl.pathname.startsWith('/admin')) {
const session = await auth(); // Edge Runtime で動く必要あり
if (!session?.user) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (session.user.role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 });
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'], // /admin 以下のみ発火
};利点と注意点:
- 利点: 中央集権的に認可を管理できる、Server Component 実行前に止まるので RSC PII 漏洩ゼロ、path pattern で対象を絞れる。
- 注意 1: middleware は Edge Runtime で動くため Node.js API (fs, crypto の Node 版など) が使えない。Auth.js の `auth()` は Edge 対応版を使う必要あり。
- 注意 2: matcher で対象を絞らないと全リクエストで DB を叩くのでコストが跳ね上がる。cookie 存在チェックのみ middleware、 詳細検証は page.tsx で行う 2 段構えが実務的。
- 注意 3: middleware で DB 遅延があると LCP に 影響する (数十 ms → 100ms+)。cookie/JWT ベースの軽量検証が推奨。
6. 正しい設計パターン C — Next.js 15 forbidden() boundary
Next.js 15.1 で experimental フラグ `authInterrupts: true` を有効に すると、`forbidden()` / `unauthorized()` が使えるようになります。
// next.config.ts
export default {
experimental: {
authInterrupts: true, // ✅ forbidden() / unauthorized() を有効化
},
};
// app/admin/forbidden.tsx (403 UI)
export default function Forbidden() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">403 Forbidden</h1>
<p className="mt-4 text-slate-600">
このページへのアクセス権がありません。
</p>
</div>
</div>
);
}
// app/admin/users/page.tsx
import { forbidden } from 'next/navigation';
import { auth } from '@/lib/auth';
export default async function UsersPage() {
const session = await auth();
if (!session?.user || session.user.role !== 'admin') {
forbidden(); // ✅ throw され、forbidden.tsx がレンダリングされる
}
const users = await db.select().from(usersTable);
return <UserList users={users} />;
}forbidden() の特徴:
- URL は変わらない: /admin/users のまま 403 UI が表示される。ユーザーは「このページに来たが権限がない」ことが 明確。
- HTTP status 403: 検索エンジンや外部ツールから 見ても正しいステータス。SEO でも「Forbidden なので index しない」 と適切に扱われる。
- boundary で切れる: forbidden.tsx の外側の layout は描画される (admin layout は表示されないが、root layout の ナビは表示可能)。boundary 設計で柔軟に UX を組める。
- Error Boundary と別枠: 意味論的に「認可失敗」 であることが明確。Error Boundary で扱うと「バグと認可失敗の 区別がつかない」問題を解消。
7. layout.tsx で認可する限界 — なぜ根本的に不向きか
「layout.tsx で認可すればいい」と考える人が多いのですが、これは 構造的に不可能です。以下 3 点で説明します。
- layout は子の実行を制御できない: React Server Component の render モデルでは、layout と children (page) は 並列で実行される。layout が return null しても page の実行は 継続する。この設計は「layout の renderer が children の renderer に依存しない = streaming SSR の高速化のため」の意図的な仕様。
- throw しても page の実行は止まる保証がない: layout.tsx で `throw new Error()` してもタイミングによっては page.tsx の実行が既に始まっており、DB access までは走ってしまう ケースがある。React 19 の Suspense モデルでは並列度が更に上がる ので、layout の throw に依存する設計は今後より不安定に。
- redirect() も layout で使うのが公式非推奨: Next.js の doc 上、redirect() は Server Actions / route handlers / page.tsx 冒頭での使用が推奨されており、layout での使用は「動くが推奨されない」 扱い。layout での redirect は「無限ループを生みやすい (redirect 先の layout でも同じ判定が走る)」問題もある。
結論として、layout.tsx は「認可判定の場所」ではなく「認可 済みの後で共通 UI を出す場所」と割り切るのが正しい設計です。 認可判定は page.tsx 冒頭 or middleware or Server Actions の冒頭で 行う。
8. teen-earn での実修正 — B571 の実例
ClearNets の teen-earn (sukikatsu マーケット) では 2026-08-15 に 本問題を発見、2 コミット (f31e9d4 → 6043085) で修正しました。以下、 実際のコード差分を模した例で説明します。
修正前: app/admin/layout.tsx で認可、page.tsx は 認可なし。
// app/admin/layout.tsx (BEFORE - バグあり)
import { auth } from '@/auth';
export default async function AdminLayout({ children }) {
const session = await auth();
if (!session?.user || !session.user.isAdmin) {
return null; // ❌
}
return <div>{children}</div>;
}
// app/admin/users/page.tsx (BEFORE - 認可なし)
import { db } from '@/db';
import { users } from '@/db/schema';
export default async function AdminUsersPage() {
const rows = await db.select().from(users); // 🔴 全ユーザー PII 取得
return <UserTable users={rows} />;
}修正後: 共通ヘルパ getAdminSession() を新設、 各 page.tsx 冒頭で呼ぶ。layout.tsx は「認可済み後の共通 UI」に集中。
// lib/admin-auth.ts (新設)
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
// AdminForbidden はエラーではなく通常の Component
export function AdminForbidden() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-3xl font-bold">403 Forbidden</h1>
<p className="mt-2 text-slate-600">
管理者権限が必要なページです。
</p>
</div>
</div>
);
}
// 使用側で early return する pattern (Next.js 15 forbidden() 非採用の場合)
export async function requireAdminOrReturn() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
if (!session.user.isAdmin) {
return { forbidden: true as const, session: null };
}
return { forbidden: false as const, session };
}
// app/admin/users/page.tsx (AFTER - 修正済み)
import { requireAdminOrReturn, AdminForbidden } from '@/lib/admin-auth';
import { db } from '@/db';
import { users } from '@/db/schema';
export default async function AdminUsersPage() {
const authResult = await requireAdminOrReturn();
if (authResult.forbidden) {
return <AdminForbidden />; // ✅ DB access 前に return
}
// ここに来るのは admin のみ
const rows = await db.select().from(users);
return <UserTable users={rows} />;
}
// app/admin/layout.tsx (AFTER - 共通 UI に集中)
import { AdminNav } from '@/components/AdminNav';
export default function AdminLayout({ children }) {
// 認可判定は page.tsx に移譲。layout はナビ等の共通 UI 専用
return (
<div className="flex">
<AdminNav />
<main className="flex-1">{children}</main>
</div>
);
}修正後の検証:
# 未認証で curl → RSC payload に PII なし
$ curl -s -H 'RSC: 1' https://teen-earn.example.com/admin/users
0:["$","html",null,{"lang":"ja","children":[
["$","div",null,{"className":"flex","children":[
["$","nav",null,{...AdminNav...}],
["$","main",null,{"children":[
["$","div",null,{"className":"min-h-screen flex items-center justify-center","children":[
["$","div",null,{"children":[
["$","h1",null,{"children":"403 Forbidden"}],
["$","p",null,{"children":"管理者権限が必要なページです。"}]
]}]
]}]
]}]
]}]
]}]
# ✅ users テーブルの PII は 1 件も含まれない8.5 追加パターン — Server Actions と Route Handlers での認可
Server Component だけでなく、App Router には Server Actions と Route Handlers という 2 つの「サーバ側実行環境」があり、これらも 認可漏れのリスクがあります。以下、それぞれの推奨パターンを整理 します。
Server Actions での認可: Server Actions は form submit や JS からの POST で任意のユーザーが叩ける、実質的な RPC エンドポイントです。フォーム属性で認可が付いていても、curl で直接 叩けば発動するので、必ずサーバ側で認可検証が必須です。
// app/admin/users/actions.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/db';
import { users } from '@/db/schema';
import { revalidatePath } from 'next/cache';
// ヘルパを 1 箇所にまとめる
async function requireAdmin() {
const session = await auth();
if (!session?.user || !session.user.isAdmin) {
throw new Error('Forbidden');
}
return session;
}
export async function deleteUser(userId: string) {
await requireAdmin(); // ✅ 必ず冒頭で認可
await db.delete(users).where(eq(users.id, userId));
revalidatePath('/admin/users');
}
export async function updateUserRole(userId: string, role: string) {
await requireAdmin(); // ✅ 別 action も同じヘルパで統一
await db.update(users).set({ role }).where(eq(users.id, userId));
revalidatePath('/admin/users');
}Server Actions では throw されたエラーはクライアントに flight 経由で 伝播しますが、そのままでは stack trace が漏れる可能性があるので (Next.js 14+ は production では自動的に redact しますが)、意味の ある UX にするなら return-value でエラーを返すのが良い設計です。
// return-value pattern (better UX)
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user?.isAdmin) {
return { success: false as const, error: 'Forbidden' };
}
await db.delete(users).where(eq(users.id, userId));
revalidatePath('/admin/users');
return { success: true as const };
}
// クライアント側
'use client';
export function DeleteButton({ userId }: { userId: string }) {
return (
<form action={async () => {
const result = await deleteUser(userId);
if (!result.success) {
toast.error(result.error);
}
}}>
<button type="submit">削除</button>
</form>
);
}Route Handlers での認可: app/api/* 配下の Route Handlers (旧 API Routes) も同様に認可必須です。JSON API として外部からも叩かれる想定なら、CORS 設定・rate limit・API key 検証も併せて設計する必要があります。
// app/api/admin/users/route.ts
import { auth } from '@/auth';
import { db } from '@/db';
import { users } from '@/db/schema';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await auth();
if (!session?.user?.isAdmin) {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: 403 }
);
}
const rows = await db.select().from(users);
return NextResponse.json({ users: rows });
}
export async function DELETE(request: Request) {
const session = await auth();
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId } = await request.json();
await db.delete(users).where(eq(users.id, userId));
return NextResponse.json({ success: true });
}3 レイヤー (Server Component / Server Actions / Route Handlers) 全て で認可を必須にすることで、どのエントリーポイントから叩かれても PII 漏洩を防ぐ多層防御が成立します。ClearNets の teen-earn ではlib/auth-guards.ts に requireAdmin() /requireUser() / requireOwner() の 3 ヘルパを 用意し、全ての Server 側実行の冒頭で明示的に呼ぶ設計にしています。
9. Playwright + curl での検証方法
この種のバグは「エラー」ではなく「正常なレスポンス」として発生 するため、通常のテストでは検知できません。Playwright + curl で 「未認証状態で RSC payload に PII が含まれないこと」を明示的に テストする必要があります。
// tests/e2e/admin-rsc-leak.spec.ts
import { test, expect } from '@playwright/test';
test('未認証で /admin にアクセスしても RSC payload に PII が含まれない', async ({ request }) => {
const response = await request.get('/admin/users', {
headers: {
'RSC': '1',
'Next-Router-State-Tree': encodeURIComponent(JSON.stringify(['', {}])),
},
// Cookie は付けない = 未認証状態
});
const body = await response.text();
// PII が含まれていないことを検証
expect(body).not.toMatch(/@example\.com/); // email
expect(body).not.toMatch(/"role":"admin"/); // role フィールド
expect(body).not.toMatch(/"phoneNumber"/); // phone number
expect(body).not.toMatch(/"birthDate"/); // 誕生日
expect(body).not.toMatch(/"isMinor":true/); // 未成年 flag
// 代わりに forbidden UI が含まれることを検証
expect(body).toMatch(/403 Forbidden/);
});
test('管理者以外のユーザーで /admin にアクセスしても PII 漏れなし', async ({ request, context }) => {
// 一般ユーザーとしてログイン (Cookie 設定)
await context.addCookies([{
name: 'session',
value: 'user_role_session_token',
domain: 'localhost',
path: '/',
}]);
const response = await request.get('/admin/users', {
headers: { 'RSC': '1' },
});
const body = await response.text();
expect(body).not.toMatch(/@example\.com/);
expect(body).toMatch(/403 Forbidden/);
});CI で shell スクリプトから curl で監査する簡易版:
#!/bin/bash
# scripts/audit-rsc-leak.sh
# CI で全 protected route を未認証で叩いて PII 漏れを検知
URLS=(
"https://your-app.com/admin/users"
"https://your-app.com/admin/orders"
"https://your-app.com/admin/settings"
)
FAILED=0
for url in "${URLS[@]}"; do
body=$(curl -s -H 'RSC: 1' -H 'Next-Router-State-Tree: %5B%22%22%2C%7B%7D%5D' "$url")
# PII の疑い keyword を検索
if echo "$body" | grep -qE '(@example\.com|"role":"admin"|"phoneNumber"|"birthDate")'; then
echo "❌ PII leak detected at: $url"
FAILED=1
else
echo "✅ $url: OK"
fi
done
exit $FAILED9.5 実務での落とし穴 5 事例
ClearNets の複数プロダクトで App Router 認可を運用してきた中で 遭遇した典型的な落とし穴を 5 つ紹介します。teen-earn の B571 修正 以降、以下のパターンは全て CI レベルで防止するようにしました。
- 落とし穴 1: loading.tsx で PII を表示 — loading.tsx は Suspense boundary の fallback として即座に 表示される Server Component。認可検証が終わる前にレンダリング されるので、ここで
{user.email}のような 表示をすると PII が漏れる。loading.tsx は認可情報を一切扱わない 静的 UI にすべき。 - 落とし穴 2: parallel routes で認可漏れ — Next.js の parallel routes (
@modal/@sidebarなど) は独立して render されるため、各 slot で個別に認可を書く 必要がある。親 layout での認可では slot は保護されない。共通 ヘルパを各 slot の default.tsx / page.tsx 両方に配置する。 - 落とし穴 3: intercepting routes での認可迷子 —
(.)modal/page.tsxのような intercepting routes は、モーダル表示時とフルページ遷移時で render context が異なる。 両方のケースで認可が発火するようにテストが必要。 - 落とし穴 4: 静的生成 (ISR) と認可の混同 —
generateStaticParamsで prerender される ページで認可を書いても、ビルド時に「認可失敗の 403 ページ」が 静的キャッシュされる可能性がある。認可が必要なページは必ず動的 レンダリング (export const dynamic = 'force-dynamic') にする。 - 落とし穴 5: metadata 関数で PII 漏洩 —
generateMetadata()も Server で実行される関数で、 その戻り値は HTML head に埋め込まれる。ここで DB fetch した PII (ユーザー名等) を title に埋めると、そのページの HTML を 誰でも取得できる場合に漏洩する。metadata で扱うのは公開可能な 情報のみに限定する。
これら 5 パターン全てを CI の Playwright テストで自動検知する ことで、開発中の PR で漏洩を止められます。手動レビューだけに 頼らない設計が重要です。
10. まとめ — 認可は「表示」ではなく「実行の停止」で行う
本記事の要点を 5 つに集約します。
- Next.js App Router の RSC ペイロードは Server Component の 実行結果を全て含む。「HTML 表示を止める」だけでは PII は 漏れる。
- layout.tsx の `if (!admin) return null` は認可として無効。 子 page.tsx の Server Component は実行され、その結果は RSC ペイロード に直列化される。
- 認可は page.tsx 冒頭 / middleware / Server Actions 冒頭で 「実行を停止する」。redirect() / forbidden() / throw で Server Component の実行そのものを止める。
- Next.js 15 の forbidden() が意味論的にも最適。 `authInterrupts: true` を有効化して、`forbidden.tsx` boundary で 403 UI を出す設計が 2026 年の推奨。
- Playwright / curl で「未認証で RSC payload に PII が 含まれない」を CI で検証する。この種のバグは通常テストで 検知できないので、明示的な監査が必要。
ClearNets の teen-earn では B571 でこのバグを発見・修正し、 以降は全 protected route に対して curl での RSC payload 監査を CI に組み込んでいます。App Router を本番運用する全開発者にとって、 今すぐ既存コードの監査をおすすめします。
11. FAQ
本記事の要点を 10 問の FAQ にまとめました。JSON-LD の FAQPage 構造化データも埋め込んでいるため、Google 検索でリッチリザルト表示 の対象になります。
Q1. layout.tsx で `if (!admin) return null` にすれば認可は成立しませんか?
成立しません。これは App Router で最もよく発生する認可バグです。 layout.tsx が返す null は「HTML 上の表示を止める」だけで、子ページの Server Component 自体は実行されます。実行された子コンポーネントの props / return value は React Server Components (RSC) ペイロード (application/octet-stream の serialized flight data) に直列化され、 ブラウザに送信されます。ブラウザ側で React が「null layout」を 見て何も描画しないため、視覚的には空ページに見えますが、Network タブで RSC payload の生データを見ると、その中に全ユーザーの email や role、PII が含まれています。攻撃者は curl で `?_rsc=` パラメータ 付きで直接叩けば、この payload を平文で取得できます。
Q2. RSC ペイロードとは何ですか?どこで確認できますか?
RSC ペイロード (React Server Components payload) は、Server Component の実行結果を Client に送るために使う React 独自のシリアライズ形式 です。JSON に似ていますが、React 要素・関数参照・Suspense 境界などを 表現できる拡張形式で、Content-Type は text/x-component。ブラウザ側では Next.js のクライアント ランタイムが RSC ペイロードを受け取って React 要素ツリーに再構築 します。確認方法は 3 つ: (1) Chrome DevTools の Network タブで文書 遷移時のリクエストに ?_rsc= パラメータ付きのものを探し、 Response を見る、(2) curl -H 'RSC: 1' で直接 取得、(3) Next.js dev モードで ?_rsc=1 を URL に付ける。 中身は文字列 + JSON 混在の独特なフォーマットで、その中に Server Component の props や return value がそのまま埋め込まれています。
Q3. middleware.ts で認可すれば安全ですか?
安全ですが、いくつか注意が必要です。middleware は全リクエストの 最初に走るため、認可失敗時に NextResponse.redirect() やNextResponse.next({status: 403}) を返せば Server Component は実行されず、RSC ペイロードにも PII は含まれません。 ただし (1) middleware は Edge Runtime で動くので Node.js API が 使えない、(2) セッション検証のために DB を叩くと遅い (Edge から DB へのラウンドトリップ)、(3) matcher で対象を絞り忘れると全 リクエストで DB を叩いてコスト爆発、の 3 点に注意。ClearNets の 実装では middleware で軽量なセッション cookie 存在チェックのみ 行い、詳細な role 検証は各 page.tsx の冒頭で getServerSession() する 2 段構えを推奨しています。
Q4. Next.js 15 の forbidden() は何が違いますか?
Next.js 15.1 で experimental フラグ authInterrupts: true を有効にすると、import { forbidden } from 'next/navigation' で forbidden() 関数が使えるように なります。これは Server Component の実行途中で throw され、最も 近い forbidden.tsx ファイルに定義された UI がレンダリング されます。redirect() と違うのは (1) URL 遷移を伴わない (現在の URL のまま 403 UI 表示)、(2) HTTP status code が 403 になる、 (3) forbidden.tsx boundary の外側のコンポーネントは 実行されない、の 3 点。App Router で認可失敗を「Suspense boundary と同じ扱い」で流せるので、Error Boundary で扱うより意味的に正しい 設計です。unauthorized() も同時に導入されました (401)。
Q5. getSession() を各 page.tsx の冒頭に書くと重複しませんか?
重複しますが、これが最も安全な設計です。Next.js の React Server Components は React.cache() で自動的にリクエスト内で 重複排除されるため、同じ getSession() を複数箇所で呼んでも DB は 1 回しか叩きません。Auth.js (旧 NextAuth.js) の場合、auth() 関数自体が内部で cache されているので、layout.tsx と page.tsx の両方で auth() を呼んでも DB へのアクセスは 1 リクエストにつき 1 回です。逆に「重複を避けるために layout.tsx でだけ検証」する設計は、 本記事で示した RSC PII 漏洩バグを生みます。DRY より安全性を優先し、 認可が必要な page.tsx の冒頭で明示的に検証するのが 2026 年の推奨 パターンです。
Q6. redirect() と forbidden() ではどちらを使うべきですか?
認可失敗の意図によって使い分けます。(a) 未ログイン (認証されて いない) 場合は redirect('/login') でログイン ページに送るのが親切。ユーザーは「ログインすれば見られる」ことが 分かる。(b) ログイン済だが権限がない場合は forbidden() で 403 表示。ユーザーは「自分のアカウントでは見られない」ことが 明確になる。(c) セキュリティ的に URL の存在自体を隠したい場合はnotFound() で 404 表示。攻撃者は「そもそも URL が 存在するか」を判定できない。ClearNets の teen-earn admin では (a) 未ログイン → /login redirect、(b) ログイン済で非 admin → forbidden() で 403 の 2 段設計。unauthorized() (401) は Web 標準では「認証情報が 必要 (Basic 認証など)」の意味なので、Web アプリでは通常 forbidden() のほうが適切です。
Q7. Server Actions ではどう認可すべきですか?
Server Actions は POST の RPC 的な性質があるため、認可漏れは即座に 「任意のユーザーが特権操作を叩ける」に直結します。設計原則は (1) 全ての Server Action の冒頭で必ず const session = await auth() して認証確認、(2) role 検証が必要な操作では if (session?.user?.role !== 'admin') throw new Error('Forbidden') を明示、(3) DB access には Prepared Statement + user_id filter を必ず入れる (SQL injection 防止 + IDOR 防止)、の 3 段。特に (2) は「throw すると 500 が返るが Server Action の返り値としては { error: 'Forbidden' } を返して UI 側でハンドリング」 が UX 的に良い。共通ヘルパ requireAdmin() を作って全 Server Action の冒頭に置くパターンが再利用しやすい。
Q8. Sentry で RSC PII 漏洩を検知できますか?
難しいです。RSC PII 漏洩は「エラー」ではなく「正常なレスポンス」 として発生するため、Sentry のエラートラッキングでは検知できません。 検知するには (1) Playwright / e2e テストで「未ログイン状態で /admin にアクセスして RSC payload に PII が含まれていないか」を assert する、 (2) CI で curl -H 'RSC: 1' で叩いてレスポンスに@example.com や email フィールドが含まれる か正規表現で検知、(3) 本番でも定期的に synthetic monitoring で外部 から叩いて検知、の 3 段が現実解。ClearNets の teen-earn では GitHub Actions で PR ごとに Playwright で認可 e2e を回し、本番でも UptimeRobot の keyword 検索モードで検知するようにしています。「認可ミスは静か に失敗する」ことを設計時点で認識するのが重要です。
Q9. 既存の App Router プロジェクトを監査するにはどこから始めますか?
3 ステップで監査するのが効率的です。(1) grep -rn 'return null' app/ で全 layout.tsx / page.tsx を洗い出し、認可判定に基づいて null を返している箇所を 特定。これらは全て RSC PII 漏洩の候補。(2) 各認可対象ページに対してcurl -H 'RSC: 1' https://your-app.com/admin を 未認証で叩き、レスポンスに PII (email / phone / role / user_id) が 含まれるか確認。(3) 各 page.tsx の冒頭で await auth() して session 検証しているか確認。していないなら追加。この 3 ステップを全 protected route に適用すれば漏洩を洗い出せる。ClearNets の teen-earn では 2026-08-15 に類似の監査を実施し、6 箇所のバグを 発見・修正しました。既存プロジェクトを継承したら真っ先にやるべき 作業です。
Q10. この問題は Next.js 特有ですか?他のフレームワークでは?
Next.js の App Router 特有ではありませんが、Server Components + streaming SSR を採用する他フレームワーク (Remix v3 の RSC 対応, Astro の Server Islands, TanStack Start) でも構造的に同じリスクが あります。原理は「サーバでコンポーネントを実行 → 結果をブラウザに 送る」パイプラインで、認可を「表示レイヤーだけで止める」設計だと 必ずリークが起きる。対策の原則は共通で「認可失敗時にコンポーネント を実行しない」ことです。Pages Router (Next.js 12 以前) や従来の SSR (getServerSideProps) では、レスポンスは HTML のみで RSC payload が存在しないので、この特定のバグパターンは発生しません (代わりに 『dehydrated state に PII を埋め込むリスク』が別途あります)。App Router 移行時にはこの認可設計の見直しが必須です。
ClearNets のセキュリティ運用
Web / API / DB を全レイヤーで多層防御
ClearNets では Next.js App Router / Neon Postgres / Auth.js を 全 5 プロダクトで共通スタックとして採用し、認可・入力検証・監査 ログ・秘密情報管理を共通ライブラリ化して運用しています。本記事で 扱った RSC PII 漏洩のような「静かなバグ」を CI で検知する仕組みも 全プロダクトに展開中です。
ClearNets プロダクト一覧を見るこの記事を書いた背景
2026-08-15 に teen-earn (sukikatsu マーケット) の管理画面で本記事で 扱った RSC PII 漏洩バグを発見し、8-17 に修正 (B571 / commit f31e9d4 + 6043085) を完了しました。教訓を Next.js コミュニティに 広く共有するために本記事を執筆しています。本記事のコード例は teen-earn の実修正を簡略化したもので、実際の運用では各プロジェクト 固有の制約に合わせた設計調整が必要です。Next.js の実装詳細は 頻繁に更新されるため、最新情報は Next.js 公式 (nextjs.org/docs) を参照ください。