Next.jsでMiddlewareだけに認証・認可を頼ってはいけない理由と安全な境界設計

Next.jsでMiddlewareだけに認証・認可を頼ってはいけない理由と安全な境界設計

DigitalCraft
10 min read

Next.js App RouterでMiddlewareだけに認証・認可を依存すると認可バイパスが生じる理由と、Server ActionsやRSCで安全なデータアクセス層(DAL)を組む多層防御の実装手順。

目次

Next.jsでMiddlewareだけに認証・認可を頼ってはいけない理由

1分でわかる要点

  • Next.jsのMiddlewareはルーティング直前のHTTPリクエスト段階で動く仕組みであり、悪意のあるクライアントから直接POSTされるServer Actionsや内部データ取得を保護する完全なセキュリティ境界にはなりません。
  • 公式ドキュメントのセキュリティ指針でも「MiddlewareはUX向上のための楽観的なリダイレクト補助であり、認可境界として単体依存してはならない」と明記されています。
  • middleware.tsだけにアクセス制御を置くと、正規表現マッチャーの抜け穴、プリフェッチによる挙動差、Server Actionsの直叩きによって認可バイパスが発生します。
  • 推奨される設計は4層防御です。Middlewareでの楽観的リダイレクト、server-onlyによるデータアクセス層(DAL)でのセッション検証、Server Actionsごとの操作認可、そしてデータベース層でのテナント絞り込みを組み合わせます。

対象読者

  • Next.js App Routerで本番Webアプリケーションを設計・開発しているエンジニア
  • Next.jsの認証・認可アーキテクチャのセキュリティレビューを担当する方
  • Pages RouterからApp Routerへ移行し、APIルートとServer Actionsの境界の違いを把握したい方

脅威モデル:Middleware単体の認証が破綻する仕組み

Next.jsでよく見られるアンチパターンの筆頭が、「middleware.tsでセッショントークンを検証しているから、その背後にある全コンポーネントやハンドラーは安全」と思い込むことです。

[悪意あるクライアント]
        |
        v
+-----------------------+   マッチャー設定の隙間?
|     Middleware.ts     | ---------------------> [未認証で直接アクセス到達]
+-----------------------+
        |  (楽観的通過)
        v
+-----------------------+
|  Server Component     | (認証済みと盲信して実行)
+-----------------------+
        |
        v
+-----------------------+
| Database / DAL        | (ユーザー検証なしで取得)
+-----------------------+

このモデルは、主に次の3つの攻撃経路で脆弱性となります。

1. Server Actionsへの直接POSTリクエスト

Server Actionsはコンパイル時に一意のアクションIDを持つ独立したPOSTエンドポイントとして生成されます。ブラウザUI上で/dashboardへの画面遷移がMiddlewareによって/loginへ弾かれていても、攻撃者がアクションID(ビルド成果物や通信ログから取得可能)を把握していれば、直接サーバーへPOSTリクエストを送信できます。

Server Action側で個別のセッション検証を行わず、「画面に入れているのだから認証済みのはず」と前提を置いていると、未認証や他人の権限でデータ更新が実行されてしまいます。

2. マッチャー(matcher)設定の盲点

middleware.tsはconfig.matcherで対象パスを制御します。しかし、否定先読み正規表現は複雑化しやすく、設定ミスが多発します。

// 危険:否定先読みの正規表現は想定外のパス漏れを生みやすい
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

典型的な失敗例:

  • Route Handlersに保護が必要なのに、/api/*を一括除外してしまう
  • パスの大文字小文字、連続スラッシュ、URLエンコードされた文字によるマッチャーのバイパス
  • /admin/exportなどの重要ルートを追加した際、ホワイトリスト定義から漏れてMiddlewareを通過してしまう

3. データキャッシュとプリフェッチによる情報漏えい

React Server Components(RSC)は機密データを含むペイロードを生成します。認可がMiddlewareだけに依存しコンポーネントのデータ読み出し時に検証されない場合、キャッシュ設計やプリフェッチの組み合わせによっては、共有キャッシュに他人のデータ断片が残るリスクが生じます。

安全な4層防御アーキテクチャ

App Router環境では、防御の役割を次の4つのレイヤーに明確に分離します。

| レイヤー | 実装場所 | 役割 | セキュリティ上の位置づけ | | :------------------------ | :--------------------- | :------------------------- | :--------------------------------------------------------------- | | 第1層:ルーティング | middleware.ts | 楽観的な画面リダイレクト | 非セキュリティ境界(未ログインユーザーの素早いログイン画面誘導) | | 第2層:データアクセス | src/dal/*.ts | セッション検証・データ取得 | セキュリティ境界(server-onlyとReact.cache()による一元管理) | | 第3層:データ更新 | 'use server' Actions | 操作単位の権限・入力検証 | セキュリティ境界(ロール・所有権・スキーマ検証の強制) | | 第4層:永続化 | ORM / DBクエリ | テナント・所有者スコープ | 堅牢な境界(クエリに必ずuserIdやorganizationIdを含める) |

実装:データアクセス層(DAL)の構築手順

データアクセス層(Data Access Layer)の原則は、プライベートデータを読み取るすべてのコンポーネントやアクションが、検証済みサーバー関数から直接セッションを受け取ることです。

手順1:server-onlyでDALを隔離する

DALのコードが誤ってクライアント側のJavaScriptバンドルに混入しないよう、公式のserver-onlyパッケージを導入します。

npm install server-only

手順2:メモ化されたセッション検証の実装

src/dal/session.tsを作成します。React.cache()を使用することで、1回のリクエスト内で複数のServer Componentがセッションを参照しても、重複したトークン復号やDB照会が発生しません。

// src/dal/session.ts
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export type SessionPayload = {
  userId: string;
  role: 'admin' | 'member';
  organizationId: string;
};

// React.cacheでリクエスト単位の実行結果をメモ化
export const verifySession = cache(async (): Promise<SessionPayload> => {
  const cookieStore = await cookies();
  const token = cookieStore.get('session_token')?.value;

  if (!token) {
    redirect('/login');
  }

  const session = await decryptAndValidateToken(token);
  if (!session || !session.userId) {
    redirect('/login');
  }

  return {
    userId: session.userId,
    role: session.role,
    organizationId: session.organizationId,
  };
});

async function decryptAndValidateToken(token: string): Promise<SessionPayload | null> {
  // 署名検証・有効期限・発行元の確認ロジック
  try {
    return {
      userId: 'usr_2026_example',
      role: 'member',
      organizationId: 'org_main',
    };
  } catch {
    return null;
  }
}

手順3:クエリ内での所有権強制

呼び出し元から渡された引数のuserIdやorganizationIdを無条件に信用せず、必ずverifySession()の値と照合します。

// src/dal/documents.ts
import 'server-only';
import { verifySession } from './session';
import { db } from '@/lib/db';

export async function getDocumentById(documentId: string) {
  const session = await verifySession();

  // クエリ条件にorganizationIdを含めてIDORを遮断
  const document = await db.document.findFirst({
    where: {
      id: documentId,
      organizationId: session.organizationId,
    },
  });

  if (!document) {
    throw new Error('ドキュメントが見つからないか、閲覧権限がありません');
  }

  return document;
}

Server Actionsの安全な実装パターン

Server Actionsでは、画面表示を経由したかどうかにかかわらず、必ず関数内でセッションと権限を検証します。

// src/actions/update-document.ts
'use server';

import { verifySession } from '@/dal/session';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const updateSchema = z.object({
  documentId: z.string().min(1),
  title: z.string().max(120),
});

export async function updateDocumentAction(formData: FormData) {
  // 1. DAL経由でセッションと本人性を検証
  const session = await verifySession();

  // 2. 入力パラメータのバリデーション
  const parsed = updateSchema.safeParse({
    documentId: formData.get('documentId'),
    title: formData.get('title'),
  });

  if (!parsed.success) {
    return { error: '不正な入力パラメータです' };
  }

  // 3. この操作に対する権限(ロール)チェック
  if (session.role !== 'admin') {
    return { error: '管理者権限が必要です' };
  }

  // 4. テナントスコープをクエリに含めて更新
  await db.document.updateMany({
    where: {
      id: parsed.data.documentId,
      organizationId: session.organizationId,
    },
    data: {
      title: parsed.data.title,
      updatedAt: new Date(),
    },
  });

  revalidatePath('/dashboard/documents');
  return { success: true };
}

実装例はDevSnipsにある場合があります。

実装・監査チェックリスト

コードレビューやセキュリティチェック時に確認すべき項目です。

  • [ ] コンポーネント単体での検証: 機密データを表示するすべてのServer ComponentがDALを経由してセッションを取得しているか。
  • [ ] Server Actionの独立検証: すべての'use server'アクションの冒頭でverifySession()等の検証を行っているか。
  • [ ] クエリのスコープ固定: ユーザー指定のID単体でDB更新・取得を行わず、必ずセッションのorganizationIdやuserIdをクエリ条件に含めているか(IDOR防止)。
  • [ ] server-onlyの適用: すべてのDALファイルにimport 'server-only'を付与し、クライアントバンドルへの漏えいをビルド時に防いでいるか。
  • [ ] Middlewareの責務分離: middleware.tsをセキュリティ境界とみなさず、UXリダイレクトに特化させているか。

関連ガイド

参考資料

編集部のおすすめ

  1. 1Permit2とは何か?Approveが変わった理由と安全な使い方(チェックリスト)
  2. 2署名(Sign)画面の読み方|Approve/Permit/NFT全権限を30秒で判別するチェックリスト
  3. 3仕様→実装に落とすプロンプトテンプレ(AI開発)|AIに“迷わせない”仕様の書き方
  4. 4EVMのトークン承認(Approve)を見直す方法|Revokeの手順と判断基準(チェックリスト)
  5. 5曖昧な依頼を要件定義に変換する質問リスト(AI開発)|AIに実装させる前に聞くべきこと

関連記事