【Next.js 15×Auth.js v5】クロスログイン問題の本質と防止策
はじめに
Next.js 15 と Auth.js v5 を組み合わせて認証機能を実装していると、
「管理者ログイン画面で一般ユーザーが入れてしまう」や「受講者ログイン画面に管理者がログインできてしまう」などといったクロスログイン現象に悩まされたことはありませんか?
本記事では、Auth.js の内部構造と Next.js の middleware 機構を踏まえ、この現象の根本原因と対策を整理します。
対象者
- Next.js 15 で Auth.js v5 を利用しているエンジニア
- 複数ロール(一般ユーザー・管理者)を扱う Web アプリを構築している方
- middleware を使った認証・認可処理で挙動が安定しないと感じている方
なぜ「クロスログイン」が起きるのか
1. 認証処理とセッション生成が非同期で分離している
Auth.js は authorize() → jwt → session の3段階でユーザーを確定します。
この間にロール情報(user.role)が適切に渡らないと、
signIn() は成功しているのに、middleware 側では未判定のまま通過してしまいます。
結果として、「一瞬だけ未認証状態で通す」→「逆ロールのページに遷移」が発生します。
2. Edge(middleware)と API(NextAuth)の設定不一致
Next.js 15では、以下の2層が存在します。
-
/app/api/auth/[...nextauth]/route.ts(API側) -
/src/lib/auth-edge.ts(Edge側)
この2つの設定がズレていると、auth() が null を返し、誤判定による通過が起きます。
Cookie があっても role が不一致のまま認証成功扱いとなり、
「管理者画面から受講者がログイン」「受講者画面から管理者がログイン」が成立してしいます。
3. 共通の認証ストラテジーが両ログイン画面で使われている
signIn('credentials') が共通の CredentialsProvider を使っている場合、
バックエンドで role を区別していなければ「どちらから来ても成功」してしまいます。
つまり、入口で区別がつかない構造そのものが根本的な原因になります。
問題の典型例
1. 共通 CredentialsProvider の定義
// src/lib/auth.ts
import CredentialsProvider from "next-auth/providers/credentials";
export const authConfig = {
providers: [
CredentialsProvider({
name: "Credentials",
async authorize(credentials) {
const user = await db.user.findUnique({ where: { email: credentials.email } });
if (!user) return null;
// role のチェックなし → 全ロールで成功
return user;
},
}),
],
};
2. ロール別ログイン画面で同一 signIn 呼び出し
// app/admin/login/page.tsx
await signIn("credentials", { email, password });
// app/login/page.tsx
await signIn("credentials", { email, password });
結果:
| ログイン画面 | 入力したアカウント | 期待される挙動 | 実際の挙動 |
|---|---|---|---|
/admin/login |
USER | 拒否される |
/ に遷移 |
/login |
ADMIN | 拒否される |
/admin-panel に遷移 |
修正版コード:構造的に防ぐ3層ガード
1. Auth設定を単一化し、roleをJWTに埋め込む
// src/lib/auth-shared.ts
export const authConfig = {
secret: process.env.AUTH_SECRET,
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user?.role) token.role = user.role;
return token;
},
async session({ session, token }) {
if (token.role) session.user.role = token.role;
return session;
},
},
};
両方の環境で同じ authConfig を import する。
// src/lib/auth-edge.ts
import NextAuth from "next-auth";
import { authConfig } from "./auth-shared";
export const { auth } = NextAuth(authConfig);
2. ログイン時に roleScope を明示
// app/admin/login/page.tsx
await signIn("credentials", { email, password, roleScope: "ADMIN" });
// authorize()
if (credentials.roleScope && user.role !== credentials.roleScope) return null;
こうすることで、異なるロールでのログインを物理的に拒否できる。
3. middleware でロールに基づく経路制御
import { auth } from "@/lib/auth-edge";
import { NextResponse } from "next/server";
export async function middleware(req) {
const session = await auth();
const role = session?.user?.role;
const pathname = req.nextUrl.pathname;
if (pathname.startsWith("/admin") && role !== "ADMIN") {
return NextResponse.redirect(new URL("/login", req.url));
}
if (pathname.startsWith("/user") && role !== "USER") {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
return NextResponse.next();
}
まとめ
Auth.js v5 の環境では、認証・セッション・ルーティングが完全に独立して動きます。
そのため、1箇所でチェックすれば安全というわけにはいきません。
クロスログイン問題は、非同期な認証状態の不整合や role 伝播の欠落の可能性が高いです。
解決策は、次の3ステップで確認していくと解決しやすいです。
| 層 | 対応内容 | チェック対象 |
|---|---|---|
| UI | signIn時に roleScope を明示 | 入力段階で誤認防止 |
| API | authorize / callback で不一致拒否 | ロール整合性保証 |
| middleware | 経路単位のロール検証 | 経路制御・防御層 |
おわりに
私自身、Next.js 15 と Auth.js v5 を導入した際に、「ログイン成功なのにユーザー/管理者のダッシュボードが逆」というバグの解消に悩まされました。原因を辿れば、APIとmiddleware、そしてUIの三者が別々の非同期レイヤーで動いていたことが本質でした。本記事がもし同じ状況に直面している方の参考になれば幸いです。
株式会社ONE WEDGE
【Serverlessで世の中をもっと楽しく】
ONE WEDGEはServerlessシステム開発を中核技術としてWeb系システム開発、AWS/GCPを利用した業務システム・サービス開発、PWAを用いたモバイル開発、Alexaスキル開発など、元気と技術力を武器にお客様に真摯に向き合う価値創造企業です。
Discussion