ClearNets

Navigation

ホーム

トップ / プロダクト一覧

会社紹介

ClearNets の考え方

ブログ

開発ノート・設計思想

コツコツ。

和モダン習慣トラッカー

Merci

チップ決済 SaaS

Kotodama

AI 占い × キャラ

Paircon

家族見守り

家族おでかけガイド

週末のおでかけ先

コツコツ。を試す

← ブログ·技術·

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 は以下のように動きます:

  1. リクエストが来る (未ログインで /admin/users)
  2. layout.tsx と page.tsx が並列で実行される (Next.js の parallel rendering)
  3. layout.tsx は auth() を呼んで null を返す
  4. page.tsx は auth() を呼ばずに DB から全ユーザーを取得
  5. React は 「layout が null → 子は描画しない」と判断
  6. しかし page.tsx の Server Component は既に実行済で、 その return value (全ユーザー DB レコード) が RSC ペイロードに 直列化される
  7. ブラウザは RSC ペイロードを受信、React は layout=null を見て 何も描画しない → 視覚的には空ページ
  8. 攻撃者は 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 点で説明します。

  1. layout は子の実行を制御できない: React Server Component の render モデルでは、layout と children (page) は 並列で実行される。layout が return null しても page の実行は 継続する。この設計は「layout の renderer が children の renderer に依存しない = streaming SSR の高速化のため」の意図的な仕様。
  2. throw しても page の実行は止まる保証がない: layout.tsx で `throw new Error()` してもタイミングによっては page.tsx の実行が既に始まっており、DB access までは走ってしまう ケースがある。React 19 の Suspense モデルでは並列度が更に上がる ので、layout の throw に依存する設計は今後より不安定に。
  3. 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.tsrequireAdmin() /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 $FAILED

9.5 実務での落とし穴 5 事例

ClearNets の複数プロダクトで App Router 認可を運用してきた中で 遭遇した典型的な落とし穴を 5 つ紹介します。teen-earn の B571 修正 以降、以下のパターンは全て CI レベルで防止するようにしました。

  1. 落とし穴 1: loading.tsx で PII を表示loading.tsx は Suspense boundary の fallback として即座に 表示される Server Component。認可検証が終わる前にレンダリング されるので、ここで {user.email} のような 表示をすると PII が漏れる。loading.tsx は認可情報を一切扱わない 静的 UI にすべき。
  2. 落とし穴 2: parallel routes で認可漏れNext.js の parallel routes (@modal / @sidebar など) は独立して render されるため、各 slot で個別に認可を書く 必要がある。親 layout での認可では slot は保護されない。共通 ヘルパを各 slot の default.tsx / page.tsx 両方に配置する。
  3. 落とし穴 3: intercepting routes での認可迷子(.)modal/page.tsx のような intercepting routes は、モーダル表示時とフルページ遷移時で render context が異なる。 両方のケースで認可が発火するようにテストが必要。
  4. 落とし穴 4: 静的生成 (ISR) と認可の混同generateStaticParams で prerender される ページで認可を書いても、ビルド時に「認可失敗の 403 ページ」が 静的キャッシュされる可能性がある。認可が必要なページは必ず動的 レンダリング (export const dynamic = 'force-dynamic') にする。
  5. 落とし穴 5: metadata 関数で PII 漏洩generateMetadata() も Server で実行される関数で、 その戻り値は HTML head に埋め込まれる。ここで DB fetch した PII (ユーザー名等) を title に埋めると、そのページの HTML を 誰でも取得できる場合に漏洩する。metadata で扱うのは公開可能な 情報のみに限定する。

これら 5 パターン全てを CI の Playwright テストで自動検知する ことで、開発中の PR で漏洩を止められます。手動レビューだけに 頼らない設計が重要です。

10. まとめ — 認可は「表示」ではなく「実行の停止」で行う

本記事の要点を 5 つに集約します。

  1. Next.js App Router の RSC ペイロードは Server Component の 実行結果を全て含む。「HTML 表示を止める」だけでは PII は 漏れる。
  2. layout.tsx の `if (!admin) return null` は認可として無効。 子 page.tsx の Server Component は実行され、その結果は RSC ペイロード に直列化される。
  3. 認可は page.tsx 冒頭 / middleware / Server Actions 冒頭で 「実行を停止する」。redirect() / forbidden() / throw で Server Component の実行そのものを止める。
  4. Next.js 15 の forbidden() が意味論的にも最適。 `authInterrupts: true` を有効化して、`forbidden.tsx` boundary で 403 UI を出す設計が 2026 年の推奨。
  5. 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.comemail フィールドが含まれる か正規表現で検知、(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) を参照ください。


← ブログ一覧に戻る