🗂

Next.jsにおける「middlewareを使わない」認証のベストプラクティス

に公開

前提知識: APIでmiddleware.tsを使わない

この記事ではこのような構造を作りますが、middleware.tsはAPIに適用すべきでしょうか?

  • /api/foo -> bearer認証
  • /dashboard/:path* -> ログイン認証

https://nextjs.org/docs/app/guides/authentication

公式ドキュメントでは「理想的な認証と認可の実装パターン」が例示されています。これに照らし合わせると、APIルートの認証はmiddleware.tsでよいのか微妙なところです。

ただ、APIキーの認証が「userがAPIを使う認可」を目的とするなら、それをmiddleware.tsに書くことはベストプラクティスではありません。

また、 公式の例では、そもそもmatcherにAPIルートが含まれておりません。

以上のことから、middleware.tsの適用範囲はAPI以外になります。

筆者も認識を間違っていたため、ここで正しい実装を例示します。

https://zenn.dev/codeciao/articles/nextjs-middleware-vulnerability-cve-2025-29927

なお、Tsu0607さんの記事により、認識の誤りに気付きました。心から感謝申し上げます。

1. middleware.ts

いきなり使ってるじゃねえか!

... Cookie等のヘッダで判断できて、なおかつデータアクセス層でも認証・認可している場合のみ、 userのリダイレクト処理はMiddleware.tsに書いて問題ありません。

https://zenn.dev/yuya0915/articles/b7d96940460a42

こちらの記事で知ったのですが、NEMOというライブラリを使います。

npm i @rescale/nemo

以下はbetter-authで認証している場合の例です。nodejsランタイムを使っています。御覧のように、APIの認証を含みません。

middleware.ts
import { createNEMO, MiddlewareConfig, NextMiddleware } from "@rescale/nemo";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";

const sessionAuthentication: NextMiddleware = async (request) => {
  try {
    const session = await auth.api.getSession({
      headers: await headers(),
    });
    if (!session) {
      return NextResponse.redirect(new URL("/sign-in", request.url));
    }
  } catch {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
};

const middlewares = {
  "/dashboard/:path*": [sessionAuthentication],
} satisfies MiddlewareConfig;

export const middleware = createNEMO(middlewares);

export const config = {
  runtime: "nodejs",
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
     */
    "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

2. Page.tsx

フロントエンドの詳しい認証は割愛します。この方法は使うライブラリに依存しているからです。

例えば、公式ドキュメントではこのような関数を定義しています。

dal.ts
import 'server-only'
 
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  if (!session?.userId) {
    redirect('/login')
  }
 
  return { isAuth: true, userId: session.userId }
})

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  // 以下略
})

ここがNext.jsの不便なところで、 Rails/Laravelなら「ミドルウェア」として書ける処理を、自分で各ActionやLoaderの前に挿入する必要があります。

layout.tsxに書く方法もありますが、バージョンにより挙動が統一されない危うさがあるので、ここでは触れません。また、結局ページ内容のロードに対する認可の判断が行えないので、layout.tsxを認証レイヤーとするのは誤りだと考えています。

Better Authの例

better authのexampleでは、このようにcatchブロックでリダイレクトしていました。

page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export default async function DashboardPage() {
  const [session] = await Promise.all([
    auth.api.getSession({
      headers: await headers(),
    })
  ]).catch((e) => {
    console.log(e);
    throw redirect("/sign-in");
  });

  return (
    <div>
      {/* ここにユーザー情報 */}
    </div>
  );
}

ただ、これはあくまでページレベルの話で、実際は先述したようにデータアクセス層でもチェックすべきです。

3. Route Handler

全てのroute.tsに同じロジックを配置するのは面倒なので、 next-zod-route でMiddlewareを定義します。

https://github.com/melvynx/next-zod-route

まだ採用数の少ないライブラリですが、同じような目的を達成できる中で、ついでにリクエストボディのバリデーションができるため採用しました。

npm i next-zod-route

3-1. まず"ミドルウェア"を定義する

ここでいう"ミドルウェア"は、一般的なMVCフレームワークの「ミドルウェア」の意味合いで使っています。

next-zod-routeのライブラリ作者もこういう命名をしています。middleware.tsとは関係ないので注意。

MiddlewareFunction として非同期関数を実装します。ここでは、t3-envで定義した API_KEY とBearerトークンを比較しています。

https://www.better-auth.com/docs/plugins/bearer

lib/api.ts
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import type { MiddlewareFunction } from "next-zod-route";
import { env } from "@/env";

function safeCompare(a: string, b: string) {
	const aBuf = Buffer.from(a);
	const bBuf = Buffer.from(b);
	return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
}

export function checkBearerToken(headers: Headers) {
	if (!env.API_KEY) {
		return false;
	}

	const authHeader = headers.get("Authorization") || "";
	const token = authHeader.match(/^Bearer\s+(.+)$/i)?.[1];

	return token && safeCompare(token, env.API_KEY);
}

export const apiKeyMiddleware: MiddlewareFunction = async ({
	next,
	request,
}) => {
	if (!checkBearerToken(request.headers)) {
		return NextResponse.json(
			{ message: "forbidden" },
			{
				status: 403,
			},
		);
	}

	return next();
};

3-2. 各ルートに適用する

  • useで共通の処理を定義します。
  • bodyでスキーマを定義します。
api/foo/route.ts
import { NextResponse } from "next/server";
import { createZodRoute } from "next-zod-route";
import { z } from "zod";
import { apiKeyMiddleware } from "@/lib/api";

const bodySchema = z.object({
  // ここにスキーマ
});

export const POST = createZodRoute()
  .use(apiKeyMiddleware)
  .body(bodySchema)
  .handler(async (_request, context) => {
    try {
      // ここに実際の処理

      return NextResponse.json(
        {
          message: "OK",
        },
        {
          status: 200,
        },
      );
    } catch (e) {
      console.error(e);
      return NextResponse.json(
        {
          message: "Internal server error",
        },
        {
          status: 500,
        },
      );
    }
  });


感想

ファイルベースルーティングは人類には早かった。

素晴らしい提案をしよう。お前もルート定義ファイルを書かないか?

https://laravel.com/docs/12.x/routing

https://guides.rubyonrails.org/routing.html

https://reactrouter.com/api/framework-conventions/routes.ts

Discussion