Next.js の proxy.ts が API キー認証のエンドポイントもログイン画面へ飛ばしていた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Next.js の proxy.ts はパスの除外リストに列挙されていない限りすべてのリクエストへセッション認証をかけるため、独自の API キー認証を持つ外部向けエンドポイントを後から追加すると、除外リストへの追加を忘れた場合にログイン画面へリダイレクトされる。
結論
Next.js の proxy.ts はパスの除外リストに列挙されていない限りすべてのリクエストへセッション認証をかけます。 matcher 自体は静的アセット以外のほぼ全パスを対象にしているため、独自の API キー認証を持つ外部向けエンドポイントを新設しても、proxy.ts の除外リストに追加し忘れれば同じ扱いになり、ログイン画面へリダイレクトされます。
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
この matcher は「静的アセット以外の全部」を対象にする書き方で、API ルートを個別に除外する仕組みは持っていません。除外は proxy 関数の中で pathname を見て判定する形になっており、そこに書き漏らしたパスは無条件でセッション認証の対象になります。
症状
/api/v1/ と /api/mcp/ は、Cookie のセッションではなく X-API-Key または Authorization: Bearer で認証する外部向けの API です。
export function validateApiKey(request: Request): boolean {
const key =
request.headers.get('X-API-Key') ??
request.headers.get('Authorization')?.replace(/^Bearer\s+/i, '')
return !!key && key === process.env.ADMIN_API_KEY
}
export async function GET(request: Request) {
if (!validateApiKey(request)) return unauthorized()
// ...
}
ルートハンドラ自身は正しくキーを検証しています。しかし proxy.ts は Cookie ベースのセッションしか見ておらず、キー認証で来たリクエストにはセッションが存在しません。
export async function proxy(request: NextRequest) {
if (request.nextUrl.pathname === '/api/health') return NextResponse.next()
// ...(Supabase セッションの取得)...
const { data: { user } } = await supabase.auth.getUser()
const { pathname } = request.nextUrl
const publicPaths = ['/login', '/register', '/auth/callback', '/reset-password', '/auth/update-password', '/privacy', '/terms', '/admin/login']
if (!user && !publicPaths.some(p => pathname.startsWith(p))) {
return NextResponse.redirect(new URL('/login', request.url))
}
// ...
}
除外されているのは /api/health だけです。/api/v1/ と /api/mcp/ は publicPaths にも入っておらず、user が無いリクエストは全て /login への NextResponse.redirect を受け取ります。API クライアントからすると、期待していた JSON の代わりに /login ページへの 302 が返ってくる形になります。
原因
/api/v1/ と /api/mcp/ のルートは 2026-07-16 に追加されました。一方 proxy.ts は直近では 2026-07-14 に触られたきりで、この2日後に増えた新しいパスのことを知りません。proxy.ts 側の除外リストは新しい API ルートを追加するたびに手で更新する必要がある構造で、ルート追加とリスト更新が別々の作業になっているため、片方だけが行われて漏れます。
/api/health の除外だけが先に存在していたのも同じ理由です。ヘルスチェック用の1パスだけは個別に追加されていましたが、その後に増えた /api/v1/ と /api/mcp/ には同じ対応が取られていませんでした。
直す
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const independentlyAuthenticatedApi =
pathname === '/api/health'
|| pathname.startsWith('/api/v1/')
|| pathname.startsWith('/api/mcp/')
if (independentlyAuthenticatedApi) return NextResponse.next()
// ...(以下、セッション認証のロジックは変更なし)...
}
/api/health の1パスだけを見ていた条件を、/api/v1/ と /api/mcp/ を含む配下判定に広げています。pathname の取得も、セッション取得より前に1回だけ行う位置へ移動しました。以前は pathname の宣言がセッション取得のあとにあり、除外判定で使うには遠い位置にありました。
matcher 自体は変更していません。静的アセット以外の全パスを対象にする設計はそのままで、除外は proxy 関数側の分岐だけで完結させています。
気づけなかった理由
/api/v1/ と /api/mcp/ が追加されたのは 2026-07-16、除外漏れが直ったのは 2026-08-13 で、約4週間その状態のままでした。 proxy.ts の直近の変更が新ルート追加の2日前だったこともあり、ルートを追加した側は proxy.ts の除外リストという別ファイルの存在を意識する機会がありませんでした。
ブラウザからの通常の画面遷移では、セッションが無ければ /login に飛ぶのが正しい挙動です。今回問題になったのは、同じ proxy.ts が「セッションで入るページ」と「キーで入る API」の両方を1つの判定ロジックで裁いていたことで、後者を新設するたびに前者向けの除外リストへの追加が要る、という依存関係が暗黙になっていた点です。この依存関係を明示する仕組み(たとえば新しいルート追加時に除外リストの確認を促すチェックリストやテスト)は、この修正の時点では追加されていません。
よくある質問
Q1proxy.ts とは何ですか?middleware.ts とは別物ですか?
Next.js 16 で middleware.ts から名前が変わったもので、役割は同じです。export する関数名も proxy に変わり、リクエストをアプリ本体に渡す前にすべてのパスを通過する処理を書きます。この記事のリポジトリも next 16.2.10 で proxy.ts を使っています。
Q2matcher で除外すれば proxy 関数側の分岐は要らないのでは?
matcher(export const config の matcher)は「proxy をそもそも実行するかどうか」を決めるフィルタで、この記事のリポジトリでは静的アセット以外のほぼ全パスが対象になっています。API ルートごとに認証方式が違うといった判断は matcher ではできないため、proxy 関数の中でパスごとに分岐する必要があります。
Q3独自の API キー認証を持つエンドポイントなら、セッション認証を素通りさせても安全ですか?
はい。X-API-Key または Authorization: Bearer のキーをルートハンドラ自身が検証しており、キーが無い・一致しないリクエストは 401 を返します。proxy 側のセッション認証は二重にかける必要が無く、素通りさせても認可は保たれます。
Q4同じ穴を防ぐにはどうすればいいですか?
この記事のリポジトリには除外漏れを検知する仕組みは無く、目視での追加のみです。新しい API ルートを追加するたびに、そのパスが proxy.ts の除外条件に含まれるかどうかを確認する一手間が要ります。
確認した環境
- Next.js 16.2.10(proxy.ts 規約)
- 2026-07-16 に /api/v1/ と /api/mcp/ を追加、2026-08-13 に除外漏れを修正
この記事の根拠
- TypeScriptファイル 1〜5行目コミット d164983
- TypeScriptファイル 26〜37行目コミット d164983
- TypeScriptファイル 42〜44行目コミット d164983
- TypeScriptファイル 1〜11行目コミット ef94f1b
- TypeScriptファイル 1〜7行目コミット 0be6d4b
- TypeScriptファイル 1〜11行目コミット 0b14b49
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。