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
},
},
})
実行順序:
- 初回ログイン:
signIn→jwt(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 |
カスタムフィールド。絶対有効期限 |
maxAge と updateAge はAuth.js v5のネイティブ機能だが、absoluteExpiry は jwt コールバック内で手動管理するカスタムフィールドである。
実装: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