この記事は約 6 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
「サインアウトしたはずなのに、ログイン状態が残っている」 — これは severity-1 リスクです。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用した tokenVersion increment + layout DB 照合 の多層防御を整理します。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
なぜ Cookie 削除に依存できないか
NextAuth の signOut() は、JWT セッション cookie を削除することでサインアウトを実現します。
import { signOut } from 'next-auth/react';
async function handleSignOut() {
await signOut(); // Set-Cookie: authjs.session-token=; Max-Age=0 を返す
router.push('/login');
}
しかし、Netlify の Set-Cookie 脱落問題 (前回 E-3 参照) により、delete cookie ヘッダが応答から消えることがあります。
| 結果 | 影響 |
|---|---|
| ブラウザ側に古い JWT cookie が残る | 次回リクエストで JWT が有効なまま使われる |
| ユーザはサインアウトしたつもり | 実質ログイン状態が継続 |
| 共有 PC / 公共端末 | 次のユーザがそのまま操作可能 ← severity-1 |
1. 解決アプローチ — tokenVersion で物理的に無効化
たすきばは、以下の 多層防御 で対処しました。
Step 1. User テーブルに tokenVersion カラムを追加
model User {
id String @id @default(uuid())
email String @unique
tokenVersion Int @default(1) // サインアウトのたびに increment
}
Step 2. JWT 発行時に tokenVersion を埋め込む
// src/lib/auth.ts (NextAuth callbacks)
callbacks: {
async jwt({ token, user }) {
if (user) {
const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
token.tokenVersion = dbUser?.tokenVersion ?? 1;
}
return token;
},
async session({ session, token }) {
session.user.tokenVersion = token.tokenVersion;
return session;
},
}
Step 3. サインアウト時に tokenVersion を increment
// src/app/api/explicit-signout/route.ts
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ ok: true });
}
await prisma.user.update({
where: { id: session.user.id },
data: { tokenVersion: { increment: 1 } },
});
// Cookie 削除も試みる (脱落しても OK)
const res = NextResponse.json({ ok: true });
res.cookies.delete('authjs.session-token');
return res;
}
Step 4. dashboard layout で毎リクエスト DB 照合
// src/app/(dashboard)/layout.tsx
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect('/login');
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { tokenVersion: true, deletedAt: true },
});
if (!dbUser || dbUser.deletedAt || dbUser.tokenVersion !== session.user.tokenVersion) {
redirect('/login?reason=session_invalidated');
}
return <>{children}</>;
}
2. どう機能するか
サインアウトフロー:
1. ユーザが「サインアウト」ボタンを押す
2. POST /api/explicit-signout が呼ばれる
3. DB の User.tokenVersion を 1 → 2 に増やす
4. (Cookie 削除も試みるが、Netlify で脱落しても OK)
5. ユーザはログイン画面にリダイレクト
次回アクセス時:
6. ブラウザに古い JWT cookie が残っていても、その JWT の tokenVersion は 1
7. dashboard layout が DB を引く → 最新 tokenVersion は 2
8. 不一致 → サインアウト扱い → /login にリダイレクト
Cookie が残っていても、実質的に無効化される のがポイントです。
3. 毎リクエスト DB 引きのコスト
dashboard layout で毎リクエスト DB を引くのは、パフォーマンス的に重そうに見えます。
| 観点 | 内容 |
|---|---|
| クエリ |
findUnique でプライマリキー検索 (インデックスヒット) |
| 取得カラム |
tokenVersion と deletedAt のみ (極小) |
| レイテンシ | 5〜10ms 程度 |
API ハンドラ全体のレイテンシ (50〜200ms) からすると、無視できる範囲。セキュリティ上の利益を考えれば十分ペイ します。
4. explicit-signout を middleware matcher から除外
地味だが重要なポイント。middleware.ts の matcher は、認証チェック対象の URL パスを指定します。
// middleware.ts
export const config = {
matcher: [
'/((?!api/auth|api/explicit-signout|_next/static|favicon.ico).*)',
// ↑ これを除外
],
};
api/explicit-signout を matcher から除外することで、サインアウト route 自体は middleware の認証チェックを通りません。これにより、認証エラーで /login にリダイレクトされる前に、サインアウト処理が完了します。
この除外を忘れると、サインアウトのつもりが /login にループしてサインアウト処理が走らない、という事故 が起きます。
5. deletedAt 照合も同時に
tokenVersion 照合と一緒に、User.deletedAt も dashboard layout で照合します。
if (!dbUser || dbUser.deletedAt || dbUser.tokenVersion !== session.user.tokenVersion) {
redirect('/login?reason=session_invalidated');
}
| シグナル | 意味 |
|---|---|
!dbUser |
DB から消えた (物理削除ありえないが念のため) |
dbUser.deletedAt |
論理削除された |
tokenVersion 不一致 |
サインアウト済み or パスワードリセット等で revoke 済み |
3 つの「無効化シグナル」を 1 箇所で扱う。管理者が User を論理削除した瞬間に、対象ユーザは次回アクセスで強制ログアウトされます。
6. tokenVersion increment が必要な他の場面
サインアウト以外にも、以下の場面で increment します。
| 場面 | tokenVersion 更新 |
|---|---|
| サインアウト | +1 |
| パスワードリセット | +1 |
| MFA リセット | +1 |
| Admin による強制ログアウト | +1 |
| 不審ログインの検知時 | +1 |
すべての「現セッションを失効させたい」場面で increment。「tokenVersion を上げれば全セッションが無効化される」 という強力なリセット手段が手に入ります。
7. 学んだこと
Cookie 削除に頼るサインアウトは、PaaS 環境では脆い。
サインアウトの実装は、ライブラリ任せにせず、サーバ側の状態 (DB) でも管理する ことで多層防御を実現します。
| 層 | 仕組み | 信頼度 |
|---|---|---|
| 1 | Cookie 削除 (best effort) | 環境依存 |
| 2 | DB の tokenVersion increment | 確実 |
| 3 | レイアウトで DB 照合 | 最終チェック |
Cookie 削除が脱落しても、実質的にサインアウトが効く 状態を作れます。
おわりに
| 層 | 仕組み |
|---|---|
| 1. JWT cookie 削除 (best effort) | Netlify で脱落する可能性あり |
| 2. DB の tokenVersion increment | 確実、サインアウトのたびに必須 |
| 3. dashboard layout で毎リクエスト DB 照合 | 不一致なら強制サインアウト |
| 4. middleware matcher から explicit-signout を除外 | 循環参照を防ぐ |
| 5. deletedAt 照合も同時に | 削除済みユーザを即サインアウト |
severity-1 リスクには、必ず 多層防御 で対処する。これが、たすきばの設計ポリシーです。
本記事の認証設計は、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ