logo
Search
Programming

Auth.js v5 でJWT二重タイムアウトを実装する:BFF + Django連携の設計パターン

#auth.js #jwt #Django REST Framework #Next.js
Apr 6th 2026
Auth.js v5 でJWT二重タイムアウトを実装する:BFF + Django連携の設計パターン

はじめに:JWT認証設計が難しい理由

JWT認証には相反する2つの要件がある。

  • セキュリティ: トークンは短命であるべき。盗まれた場合の被害を最小化できる
  • ユーザー体験: 操作中にセッションが切れるとストレスになる

この2つを両立するために、多くのシステムでは「絶対有効期限」と「アイドルタイムアウト」を組み合わせる。

要件 動作 目的
絶対有効期限(8時間) ログインから8時間後に強制失効 トークン漏洩時の被害上限を設定
アイドルタイムアウト(30分) 30分間APIアクセスがなければ失効 離席時の不正利用を防止
スライディングウィンドウ 30分以内のアクセスで有効期限を延長 アクティブユーザーの操作を妨げない

Auth.js v5はスライディングウィンドウをネイティブでサポートしているが、絶対有効期限は手動実装が必要になる。この記事では両方を組み合わせる実装パターンを解説する。

Auth.js v5のJWTセッション戦略

Auth.js v5(旧NextAuth.js v5)のJWTセッション戦略は、v4から大きく変わった。

JWE暗号化クッキー

Auth.js v5のJWTトークンは**JWE(JSON Web Encryption)**で暗号化され、HttpOnlyクッキーに格納される。

  • 暗号化方式: A256CBC-HS512(AES-256-CBC + HMAC-SHA-512)
  • 鍵導出: AUTH_SECRET からHKDFで導出。クッキー名がsaltとして使われる
  • ストレージ: __Secure-authjs.session-token という名前のHttpOnly / Secure / SameSite=Laxクッキー

v4ではJWSE(署名のみ)だったが、v5ではJWE(暗号化)に変更された。この変更により、クライアントサイドでトークンの中身を読み取ることはできない。バックエンド(Django等)でAuth.jsのトークンを直接復号するのも非現実的であり、後述のToken Exchangeパターンが推奨される理由の一つである。

jwt / session コールバック

Auth.js v5の認証フローの中核は2つのコールバックである。

// auth.ts
import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: { strategy: "jwt" },
  callbacks: {
    // 1. jwt callback: トークンの生成・更新を担当
    //    - 初回ログイン時: user, account が渡される
    //    - 以降のリクエスト: token のみ(既存トークンの読み取り)
    async jwt({ token, user, account }) {
      if (account && user) {
        // 初回ログイン時のみ実行される
        token.userId = user.id
      }
      return token
    },

    // 2. session callback: クライアントに公開する情報を整形
    //    - jwt callback の後に毎回実行される
    async session({ session, token }) {
      session.user.id = token.userId as string
      return session
    },
  },
})

実行順序:

  • 初回ログイン: signInjwt(token生成)→ session(整形)→ redirect
  • 以降のリクエスト: jwt(token読み取り・更新)→ session(整形)→ authorized(ルート保護)

Edge Runtime対応の設計分割

Next.js App RouterのMiddlewareはEdge Runtimeで動作する。Auth.js v5でMiddlewareを使う場合、Node.jsランタイム専用の処理(DBアクセス等)を分離する必要がある。

auth.config.ts  ← Edge互換。Middleware から import する
auth.ts         ← Node.js用。DBアクセスを含むプロバイダー設定
middleware.ts   ← auth.config.ts を使用
// auth.config.ts(Edge互換)
import type { NextAuthConfig } from "next-auth"

export const authConfig = {
  session: { strategy: "jwt" },
  pages: {
    signIn: "/login",
  },
  callbacks: {
    // jwt, session, authorized コールバックをここに定義
  },
} satisfies NextAuthConfig
// auth.ts(Node.js用)
import NextAuth from "next-auth"
import { authConfig } from "./auth.config"
import Credentials from "next-auth/providers/credentials"

export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      // DBアクセスを含む認証ロジック
    }),
  ],
})

二重タイムアウトの設計

なぜ片方だけでは不十分なのか

絶対有効期限だけの場合:

ユーザーがログインして10分操作し、その後7時間50分離席しても、セッションは生きている。共有PCや公共の場所では深刻なセキュリティリスクになる。

アイドルタイムアウトだけの場合:

ユーザーが自動化スクリプトやバックグラウンドのポーリングで定期的にAPIを叩いていると、セッションが永遠に延長される。トークンが漏洩した場合、攻撃者も同じ手法で無期限にセッションを維持できる。

両方を組み合わせることで「普段は操作に応じて延長されるが、どんなに操作し続けても8時間後には再認証が必要」という挙動を実現できる。

Auth.js v5での設計方針

パラメータ 役割
session.maxAge 1800(30分) クッキーのExpires。アイドルタイムアウト
session.updateAge 0 毎リクエストでクッキーを更新(スライディングウィンドウ)
token.absoluteExpiry Date.now() + 8h カスタムフィールド。絶対有効期限

maxAgeupdateAge はAuth.js v5のネイティブ機能だが、absoluteExpiryjwt コールバック内で手動管理するカスタムフィールドである。

実装:jwt callbackで二重タイムアウト

以下が二重タイムアウトの中核実装である。

// auth.config.ts
import type { NextAuthConfig } from "next-auth"

const ABSOLUTE_TIMEOUT_SECONDS = 8 * 60 * 60 // 8時間
const IDLE_TIMEOUT_SECONDS = 30 * 60          // 30分

export const authConfig = {
  session: {
    strategy: "jwt",
    maxAge: IDLE_TIMEOUT_SECONDS,  // 30分でクッキー失効
    updateAge: 0,                   // 毎リクエストでクッキー延長
  },
  callbacks: {
    async jwt({ token, user, account }) {
      // 初回ログイン時: 絶対有効期限を埋め込む
      if (account && user) {
        return {
          ...token,
          absoluteExpiry: Date.now() + ABSOLUTE_TIMEOUT_SECONDS * 1000,
        }
      }

      // 絶対有効期限チェック
      if (Date.now() > (token.absoluteExpiry as number)) {
        // 8時間経過 → セッション無効化
        return null
      }

      // ここに到達 = アイドルタイムアウト内 & 絶対有効期限内
      // maxAge + updateAge:0 の組み合わせでクッキーが自動延長される
      return token
    },

    authorized({ auth, request }) {
      // BFF APIへのリクエストのみJWT検証
      const isApiRoute = request.nextUrl.pathname.startsWith("/api")
      if (isApiRoute) {
        return !!auth
      }
      return true
    },
  },
  pages: {
    signIn: "/login",
  },
} satisfies NextAuthConfig

フローの整理:

[ユーザーログイン]
  → jwt callback: absoluteExpiry = now + 8h をトークンに埋め込み
  → クッキー保存(maxAge: 30min)

[5分後にAPIアクセス]
  → jwt callback: absoluteExpiry > now → OK
  → updateAge: 0 → クッキーのExpiresをリセット(+30min)

[さらに30分放置]
  → クッキー自体が期限切れ → auth = null → 401

[8時間アクティブに操作し続けた場合]
  → jwt callback: absoluteExpiry < now → return null → セッション強制無効化

型定義の拡張

TypeScriptで absoluteExpiry をトークンの型に追加する。

// types/next-auth.d.ts
import "next-auth/jwt"

declare module "next-auth/jwt" {
  interface JWT {
    absoluteExpiry: number
    djangoToken?: string
  }
}

実装:Next.js MiddlewareでBFF API保護

MiddlewareはBFF APIルート(/api/*)へのリクエスト時にのみJWT検証を行う。ページ遷移にはMiddlewareを適用しない設計とする。

// middleware.ts
import NextAuth from "next-auth"
import { authConfig } from "./auth.config"

export const { auth: middleware } = NextAuth(authConfig)

export default middleware

export const config = {
  // BFF APIルートのみにマッチ
  matcher: ["/api/:path*"],
}

この設計の利点:

  • ページは自由にアクセス可能: 未認証でもページを表示し、フロントエンド側で認証状態に応じたUIを出し分ける
  • API層で一括保護: 認証が必要なデータアクセスはすべてBFF API経由にすることで、保護漏れを防ぐ
  • Edge Runtimeで動作: auth.config.ts のみを使うため、Node.js依存がない

認証失敗時のレスポンス

authorized コールバックが false を返した場合、Auth.js v5はデフォルトで pages.signIn にリダイレクトする。APIルートの場合はリダイレクトではなく401レスポンスを返したいケースが多い。

// auth.config.ts の authorized コールバック(拡張版)
authorized({ auth, request }) {
  const isApiRoute = request.nextUrl.pathname.startsWith("/api")

  if (isApiRoute && !auth) {
    return Response.json(
      { error: "Unauthorized" },
      { status: 401 }
    )
  }

  return true
},

Django連携:Token Exchangeパターン

なぜAuth.jsのトークンをDjangoに直接渡せないのか

Auth.js v5のJWTはJWE(暗号化)されており、AUTH_SECRET とHKDFから導出された鍵で暗号化されている。Djangoでこの暗号化を再現するのは現実的ではない。

代わりにToken Exchangeパターンを使う。Auth.jsのJWTとは別に、Django独自のJWTを発行・管理する。

アーキテクチャ

[ログイン時]
  Browser → Next.js → Auth.js (認証)
                         ↓
                    jwt callback → Django /api/auth/token/ を呼び出し
                         ↓
                    Django JWT を取得 → Auth.js token に djangoToken として格納

[APIリクエスト時]
  Browser → /api/data → Middleware (Auth.js JWT検証)
                           ↓ OK
                      Route Handler → token から djangoToken を取り出し
                           ↓
                      Django API (Authorization: Bearer <djangoToken>)
                           ↓
                      Django が自身のJWTを独立検証

実装

// auth.ts
import NextAuth from "next-auth"
import { authConfig } from "./auth.config"
import Credentials from "next-auth/providers/credentials"

export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        // 1. ユーザー認証(DB検証等)
        const user = await verifyCredentials(credentials)
        if (!user) return null

        // 2. Django JWT を取得
        const djangoRes = await fetch(
          `${process.env.DJANGO_API_URL}/api/auth/token/`,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              username: credentials.email,
              password: credentials.password,
            }),
          }
        )
        const djangoTokens = await djangoRes.json()

        return {
          ...user,
          djangoToken: djangoTokens.access,
          djangoRefreshToken: djangoTokens.refresh,
        }
      },
    }),
  ],
  callbacks: {
    ...authConfig.callbacks,
    async jwt({ token, user, account }) {
      // 親の jwt callback を先に実行(absoluteExpiry チェック)
      const baseToken = await authConfig.callbacks.jwt({ token, user, account })
      if (!baseToken) return null

      // 初回ログイン時: Django トークンを格納
      if (account && user) {
        baseToken.djangoToken = (user as any).djangoToken
        baseToken.djangoRefreshToken = (user as any).djangoRefreshToken
      }

      return baseToken
    },
  },
})
// app/api/data/route.ts(BFF APIの例)
import { auth } from "@/auth"

export async function GET() {
  const session = await auth()
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 })
  }

  // Django APIにトークンを転送
  const res = await fetch(`${process.env.DJANGO_API_URL}/api/data/`, {
    headers: {
      Authorization: `Bearer ${session.djangoToken}`,
    },
  })

  const data = await res.json()
  return Response.json(data)
}

Django側の設定

# settings.py
from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(hours=8),  # Auth.js側と合わせる
    "REFRESH_TOKEN_LIFETIME": timedelta(hours=8),
    "ROTATE_REFRESH_TOKENS": False,
}

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
}

Django側のトークン有効期限もAuth.js側の絶対有効期限(8時間)に合わせておくと、タイムアウトの不整合を防げる。

Django側でもJWT検証すべき理由

Token Exchangeは「Django側でも検証する」設計が前提である。

  • 多層防御: Next.js BFFのバグがあってもDjango側で不正リクエストを遮断できる
  • 将来の拡張: モバイルアプリや他のクライアントがDjango APIに直接アクセスする場合にも対応可能
  • 責務の分離: 認証の責任がNext.jsに集中しない

落とし穴と対策

1. トークンリフレッシュのRace Condition

複数のAPIリクエストが同時に発生した場合、jwt コールバックが並行して実行される。Djangoのリフレッシュトークンがワンタイム(ROTATE_REFRESH_TOKENS: True)の場合、先に実行されたリクエストがトークンをローテーションし、後続のリクエストが古いリフレッシュトークンで失敗する。

対策: Django側で ROTATE_REFRESH_TOKENS: False に設定するか、Auth.js側でリフレッシュにロック機構を実装する。この記事の設計ではアクセストークンの有効期限を8時間にしているため、頻繁なリフレッシュは発生しない。

2. Edge Runtime互換

middleware.ts はEdge Runtimeで動作する。Node.jsの crypto モジュールを直接使うコードがあるとランタイムエラーになる。

対策: auth.config.ts(Edge互換)と auth.ts(Node.js用)を分割し、MiddlewareからはEdge互換のファイルのみを参照する。

3. updateAge: 0 のトレードオフ

updateAge: 0 は毎リクエストでSet-Cookieヘッダーを返すことを意味する。CDNキャッシュとの相性が悪く、レスポンスヘッダーのサイズも増える。

対策: APIルートのみにMiddlewareを適用し(matcher: ["/api/:path*"])、静的アセットやページへのリクエストではクッキー更新が走らないようにする。パフォーマンスへの影響が気になる場合は updateAge: 60(1分)程度に設定し、厳密な30分ではなく最大31分のアイドルタイムアウトとして許容する方法もある。

まとめ

本記事の設計パターンの全体像を整理する。

┌──────────────┐
│   Browser    │
└──────┬───────┘
       │ fetch('/api/...')
       ▼
┌──────────────────────────────────────┐
│  Next.js Middleware (Edge Runtime)    │
│  ┌────────────────────────────────┐  │
│  │ Auth.js jwt callback           │  │
│  │ 1. absoluteExpiry > now?       │  │
│  │ 2. authorized → auth 存在?     │  │
│  └────────────────────────────────┘  │
│  maxAge: 30min / updateAge: 0        │
└──────┬───────────────────────────────┘
       │ 検証OK
       ▼
┌──────────────────────────────────────┐
│  Next.js BFF API (Route Handler)     │
│  token から djangoToken を取り出し     │
│  Authorization: Bearer <djangoToken>  │
└──────┬───────────────────────────────┘
       │
       ▼
┌──────────────────────────────────────┐
│  Django REST Framework               │
│  simplejwt で独立検証                  │
│  ACCESS_TOKEN_LIFETIME: 8h            │
└──────────────────────────────────────┘

各コンポーネントの責務

コンポーネント 責務
Auth.js jwt callback 二重タイムアウト管理(絶対8h + アイドル30min)
Next.js Middleware BFF APIルートの認証ゲート
Next.js Route Handler Django APIへのプロキシ + トークン転送
Django simplejwt 独立したトークン検証(多層防御)

Auth.js v5の設定値まとめ

設定 説明
session.strategy "jwt" JWTセッション戦略を使用
session.maxAge 1800 アイドルタイムアウト(30分)
session.updateAge 0 毎リクエストでスライディングウィンドウ
token.absoluteExpiry now + 8h カスタムフィールドで絶対有効期限を管理

この設計により、「普段は操作に応じてセッションが延長されるが、8時間後には再認証が必要」という要件を Auth.js v5 のネイティブ機能とカスタム実装の組み合わせで実現できる。

Comments