Next.js API Route の認証ミドルウェア実装ガイド

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。
Next.js App Router で API Route に認証を実装するとき、「middleware.ts に全部書くか」「Route Handler 側で個別に書くか」で方針が定まりにくい。実用的な落とし所として、両方を組み合わせた構成がある。本記事ではその役割分担と実装コードを手順ごとに書いていく。
middleware.ts と Route Handler の役割分担
Next.js App Router には認証チェックを置ける場所が主に2つある。
| 処理 | 担当場所 |
|---|---|
| セッション(Cookie)の存在確認 | middleware.ts(Edge Runtime) |
| ロール・所有権の確認 | Route Handler 内 |
| レート制限・IP ブロック | middleware.ts または独立した WAF 層 |
| 入力値バリデーション | Route Handler 内 |
middleware.ts は Edge Runtime で動くため、Node.js の API(fs、Prisma など)が使えない。その代わり全リクエストを捕捉できるため、「ログインしているか」の判定に向いている。「この操作をしてよいか」という細かい権限確認(「この注文 ID はリクエスト元ユーザーのものか」など)は Route Handler 側に任せる。
middleware.ts でセッションを確認する
セッション管理に iron-session(v8 以降)を使う構成を例にとる。v8 は Edge Runtime に対応しており、サブパス(/edge)なしで import できる(執筆時点の仕様。バージョンによって変わる可能性があります)。
// middleware.ts(プロジェクトルート)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions } from "@/lib/session";
const PROTECTED_PREFIXES = ["/api/admin/", "/api/orders/"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = PROTECTED_PREFIXES.some((p) => pathname.startsWith(p));
if (!isProtected) return NextResponse.next();
const response = NextResponse.next();
const session = await getIronSession(request, response, sessionOptions);
if (!session.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return response;
}
export const config = {
matcher: ["/api/:path*"],
};
sessionOptions は別ファイルに切り出して、middleware.ts と Route Handler の両方から共有する。
// lib/session.ts
import type { SessionOptions } from "iron-session";
export const sessionOptions: SessionOptions = {
cookieName: "myapp_session",
password: process.env.SESSION_SECRET!, // 32文字以上のランダム文字列
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
},
};
SESSION_SECRET は .env.local に書き、本番では環境変数として渡す。Git にコミットしないこと。
Route Handler でロールと所有権を確認する
middleware.ts はセッションの「存在」しか確認しない。「この操作をしてよいか」は Route Handler 側で判断する。
// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions } from "@/lib/session";
import { prisma } from "@/lib/prisma";
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const response = new Response();
const session = await getIronSession(request, response, sessionOptions);
if (!session.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const order = await prisma.order.findUnique({
where: { id: params.id },
});
if (!order) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
if (order.userId !== session.userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json(order);
}
middleware.ts で弾いた後でも Handler 側で再確認している。将来 matcher を変更したとき、あるいは Route Handler を単体テストするときのミスを防ぐための多重チェックだ。
レート制限とログイン失敗ロックを追加する
自作ECサイトでは WAF 的な処理を proxy.ts という独立した層に集約している。Node.js サーバーとして常駐させている前提で、メモリを使ったレート制限とログイン失敗ロックの実装例を示す。
レート制限(300 req/分)
// lib/rateLimit.ts
const requestCounts = new Map<string, { count: number; resetAt: number }>();
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = requestCounts.get(ip);
if (!entry || entry.resetAt < now) {
requestCounts.set(ip, { count: 1, resetAt: now + 60_000 });
return true;
}
if (entry.count >= 300) return false;
entry.count++;
return true;
}
ログイン5回失敗で15分ロック
// lib/loginLock.ts
type LockEntry = { failCount: number; lockedUntil: number };
const lockMap = new Map<string, LockEntry>();
export function checkLoginLock(ip: string): boolean {
const entry = lockMap.get(ip);
if (!entry) return false;
return entry.lockedUntil > Date.now();
}
export function recordLoginFailure(ip: string): void {
const entry = lockMap.get(ip) ?? { failCount: 0, lockedUntil: 0 };
entry.failCount++;
if (entry.failCount >= 5) {
entry.lockedUntil = Date.now() + 15 * 60 * 1000; // 15分
entry.failCount = 0;
}
lockMap.set(ip, entry);
}
ログインの Route Handler でこれら2関数を呼ぶ。注意点として、Map を使ったこのインメモリ実装は、Node.js プロセスが常駐していることを前提にしている。Vercel などのサーバーレス環境ではリクエストごとにインスタンスが変わるため、この実装は機能しない。その場合は Redis や Upstash などの外部ストアが必要になります。
自宅 Linux サーバーで systemd 常駐させる手順は systemdサービスを自動起動させる設定手順 に詳しく書いた。
つまずきやすいポイント
matcher のワイルドカードを忘れる
// ❌ /api にしかマッチしない
export const config = { matcher: "/api" };
// ✅ サブパスまでカバーする
export const config = { matcher: ["/api/:path*"] };
matcher を変更したら console.log(request.nextUrl.pathname) を middleware.ts に仮で入れ、想定ルートが捕捉されているかすぐ確認する。
Edge Runtime で Prisma を import するとビルドエラー
middleware.ts は Edge Runtime のため、Prisma の標準クライアントを import するとビルドに失敗する。セッション確認だけを middleware.ts に置き、DB アクセスは Route Handler に閉じ込めること。Prisma の Edge 対応アダプタを使う方法もあるが、構成が複雑になる。
NextResponse.next() を使い回さないと Set-Cookie が消える
// ❌ session を書き込んだ response を捨てている
const session = await getIronSession(request, new Response(), sessionOptions);
return NextResponse.next(); // 別インスタンスなので Cookie が消える
// ✅ 同じ response を返す
const response = NextResponse.next();
const session = await getIronSession(request, response, sessionOptions);
return response;
iron-session は Set-Cookie ヘッダーを response オブジェクトに書く。別のインスタンスを返すとセッション更新が失われる。
まとめ
middleware.ts(Edge Runtime)はセッションの存在確認とレート制限に使い、Prisma などの Node.js 依存処理は書かない- Route Handler 側でロール・所有権の確認を行う二層構成にすると、将来の変更に強い
- インメモリのレート制限・ログイン失敗ロックは Node.js 常駐サーバー前提の実装。サーバーレス環境では外部ストアを使う設計に変える必要がある
次の一手は middleware.ts に console.log(request.nextUrl.pathname) を1行入れて、どのリクエストが middleware を通っているかを確認すること。matcher の設定ミスはこれで大半が分かる。
この記事で触れたもの
- Next.js 実践入門楽天で探す
よくある質問
middleware.ts に Prisma を書いたらビルドエラーになります。どうすれば直りますか?
middleware.ts は Edge Runtime で動くため、Prisma などの Node.js 依存ライブラリは使えません。セッションの存在確認だけを middleware.ts に置き、データベースへのアクセスは Route Handler 側に移してください。
Vercel にデプロイしたらレート制限が効きません。原因は何ですか?
Map を使ったインメモリのレート制限は Node.js プロセスが常駐している場合のみ機能します。Vercel などのサーバーレス環境ではリクエストごとにインスタンスが変わるため、Redis や Upstash など外部ストアへの移行が必要です。
matcher にパスを書いたのに API が保護されていません。確認方法は?
middleware.ts に console.log(request.nextUrl.pathname) を一時的に入れ、対象ルートへリクエストを送ってログが出るか確認します。/api だけでなく /api/:path* のようにワイルドカードを含めているかも見直してください。