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

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 の設定ミスはこれで大半が分かる。

この記事で触れたもの

よくある質問

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* のようにワイルドカードを含めているかも見直してください。