Next.jsでMiddlewareだけに認証・認可を頼ってはいけない理由と安全な境界設計
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リダイレクトに特化させているか。