proxy.ts Redirected API-Key Endpoints to the Login Page
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Next.js proxy.ts applies session authentication to every request not on its exemption list, so an external endpoint with its own API key auth gets redirected to the login page if you forget to add it.
Conclusion
Next.js proxy.ts applies session authentication to every request unless the path is on its exemption list. The matcher itself already covers nearly every path except static assets, so a new external endpoint with its own API key authentication is treated the same way and redirected to the login page if you forget to add it to the exemption list in proxy.ts.
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
That matcher is the “everything but static assets” form and has no mechanism for excluding API routes individually. Exclusion happens inside the proxy function by inspecting the pathname, so any path missing from there is subject to session auth unconditionally.
Symptom
/api/v1/ and /api/mcp/ are external APIs authenticated with X-API-Key or Authorization: Bearer rather than a cookie session.
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()
// ...
}
The route handlers verify the key correctly. But proxy.ts only looks at the cookie session, and a request arriving with a key has no session.
export async function proxy(request: NextRequest) {
if (request.nextUrl.pathname === '/api/health') return NextResponse.next()
// ...(fetch the Supabase session)...
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))
}
// ...
}
The only exemption is /api/health. /api/v1/ and /api/mcp/ are not in publicPaths either, so every request without a user receives a NextResponse.redirect to /login. From an API client’s point of view, the expected JSON is replaced by a 302 to the login page.
Cause
The /api/v1/ and /api/mcp/ routes were added on 2026-07-16. proxy.ts had last been touched on 2026-07-14 and knows nothing about paths that appeared two days later. Its exemption list has to be updated by hand every time an API route is added, and because adding the route and updating the list are separate tasks, only one of them happens.
The lone /api/health exemption exists for the same reason: one health-check path was added individually, and the /api/v1/ and /api/mcp/ routes that came later never got the same treatment.
The fix
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()
// ...(session authentication logic below is unchanged)...
}
The condition that looked at the single /api/health path now covers everything under /api/v1/ and /api/mcp/. Reading pathname was also moved to a single call before the session lookup; previously it was declared after the session fetch, far from where the exemption check needed it.
The matcher itself is unchanged. Covering every path except static assets stays as designed, and exclusion is handled entirely by the branch inside the proxy function.
Why it went unnoticed
/api/v1/ and /api/mcp/ were added on 2026-07-16 and the missing exemption was fixed on 2026-08-13 — about four weeks in that state. With proxy.ts last modified two days before the routes were added, whoever added them had no occasion to think about an exemption list in a different file.
For an ordinary page navigation from a browser, redirecting to /login without a session is the correct behaviour. The problem here was that the same proxy.ts arbitrated both “pages you enter with a session” and “APIs you enter with a key” through one decision path, leaving an implicit dependency: adding one of the latter requires editing an exemption list built for the former. No mechanism to make that dependency explicit — a checklist or a test triggered when a new route is added — was introduced with this fix.
よくある質問
Q1What is proxy.ts? Is it different from middleware.ts?
It is the Next.js 16 rename of middleware.ts, with the same role; the exported function is now called proxy. It runs on every path before the request reaches the app. This repository is on next 16.2.10 and uses proxy.ts.
Q2Can't matcher exclude them so the proxy function needs no branch?
matcher (the matcher in export const config) decides whether proxy runs at all, and in this repository it covers nearly every path except static assets. It cannot express 'this API route uses a different auth method', so the branch has to live in the proxy function.
Q3Is it safe to let API-key endpoints bypass session auth?
Yes. The route handlers verify the X-API-Key or Authorization: Bearer key themselves and return 401 when it is absent or wrong. Session auth in the proxy would be a second, redundant layer; skipping it keeps authorisation intact.
Q4How do I stop the same gap happening again?
This repository has nothing that detects a missing exemption; entries are added by eye. Every time an API route is added, someone has to check whether that path is covered by the exemption condition in proxy.ts.
確認した環境
- Next.js 16.2.10 (proxy.ts convention)
- /api/v1/ and /api/mcp/ added 2026-07-16, the missing exemption fixed 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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。