5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ログアウトを Cookie 削除に頼らない設計 — tokenVersion increment + layout DB 照合

5
Posted at

この記事は約 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 でプライマリキー検索 (インデックスヒット)
取得カラム tokenVersiondeletedAt のみ (極小)
レイテンシ 5〜10ms 程度

API ハンドラ全体のレイテンシ (50〜200ms) からすると、無視できる範囲。セキュリティ上の利益を考えれば十分ペイ します。


4. explicit-signout を middleware matcher から除外

地味だが重要なポイント。middleware.tsmatcher は、認証チェック対象の 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 — 公式プロダクトページ

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?