- 概要 — Next.js App Router認証の特殊性
- Next.js認証アーキテクチャの全体フロー
- ミドルウェアベース認証(middleware.ts)
- Route Handlerでの認証
- Server Actionでの認証
- Server Componentでの認証状態アクセス
- Client Componentでの認証状態管理
- ブラウザストレージ別アクセス可能性表
- Cookie設定の実践
- JWTクレームパース
- CORS設定
- ログアウトとトークン無効化
- NextAuth.js / Auth.js比較
- セキュリティトレードオフ
- チェックリスト
- よくあるバグと誤解
- 参考資料
概要 — Next.js App Router認証の特殊性
Next.js App Router は、従来の React SPA の認証とは根本的に異なるパラダイムを要求する。React 編で扱ったクライアント中心の認証はブラウザの document.cookie や fetch リクエストに依存するが、Next.js ではサーバーとクライアントの両方で認証状態を管理しなければならない。
Server Components vs Client Components認証の違い
Server Components はサーバーでレンダリングされるため、cookies() API を通じてリクエストのクッキーへ直接アクセスできる。一方 Client Components はブラウザで実行されるため HttpOnly クッキーへ直接アクセスできず、API 呼び出しで認証状態を確認する必要がある。
// Server Component — cookies() API で直接アクセス可能
import { cookies } from 'next/headers'
export default async function DashboardPage() {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (!token) {
redirect('/login')
}
const user = await verifyAndDecodeToken(token)
return <Dashboard user={user} />
}
// Client Component — API 呼び出しが必要
'use client'
import { useEffect, useState } from 'react'
export function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then((res) => res.json())
.then(setUser)
}, [])
if (!user) return <LoginButton />
return <Profile user={user} />
}
Edge Runtime vs Node.js Runtime
ミドルウェアは Edge Runtime で実行されるため、Node.js 専用のモジュール (jsonwebtoken など) は使えない。代わりに Web Crypto API ベースの jose ライブラリを使う必要がある。Route Handler と Server Action は既定では Node.js Runtime で実行されるが、export const runtime = 'edge' を指定すれば Edge でも動作させられる。
React SPAとの違い
| 項目 | React SPA | Next.js App Router |
|---|---|---|
| Cookieアクセス | document.cookie (HttpOnly 不可) | cookies() API (HttpOnly を含む) |
| 認証チェックタイミング | クライアントレンダリング後 | サーバーレンダリング時点(ミドルウェア/RSC) |
| リダイレクト | クライアントルーター | サーバーサイドの redirect() |
| トークン検証 | バックエンドAPIに委任 | ミドルウェアで直接検証可能 |
| 初期ロード | 認証状態不確定(フラッシュ) | SSRで確定された状態を伝達 |
Next.js認証アーキテクチャの全体フロー
Next.js App Router で認証リクエストが処理される全体の流れは次のとおり。
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Client Comp │ │ Form Submit │ │ fetch(/api/...) │ │
│ │ (useAuth) │ │ (Server Act) │ │ credentials: │ │
│ │ │ │ │ │ 'include' │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
└─────────┼───────────────────┼──────────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ middleware.ts (Edge Runtime) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. NextRequest からクッキーを読む │ │
│ │ 2. jose で JWT を検証 (Edge 互換) │ │
│ │ 3. 未認証 → /login へリダイレクト │ │
│ │ 4. 認証済み → リクエストヘッダーにユーザー情報を注入 (任意) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────────┼────────────────────────────────────┘
▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Server Component │ │ Server Action │ │ Route Handler │
│ cookies() 読み取り │ │ cookies() 読み取り│ │ cookies() 設定 │
│ JWT パース │ │ クッキー設定/削除 │ │ ログイン/ログアウト│
│ 条件付きレンダリング│ │ revalidate │ │ トークンリフレッシュ│
└──────────────────┘ └──────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ Backend API │
│ (認証サーバー) │
│ JWT 発行/検証 │
└──────────────────┘
中心となる原則は次のとおり。
- ミドルウェアがすべてのリクエストの最初の関門として働き、認証状態を確認する。
- Route Handlerがクッキーの設定/削除を担当する (ログイン、ログアウト、リフレッシュ)。
- Server Componentはクッキーからトークンを読み、ユーザー情報をレンダリングする。
- Server Actionはフォームベースの認証とサーバーサイドのロジックを処理する。
- Client Componentはサーバーから渡された認証状態を受け取るか、API で更新する。
ミドルウェアベース認証(middleware.ts)
ミドルウェアは、すべてのリクエストがサーバーに到達する前に実行される Edge Function だ。認証が必要なパスに対してトークンを検証し、未認証のリクエストをリダイレクトする役割を担う。
// middleware.ts (プロジェクトルート)
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify, type JWTPayload } from 'jose'
interface AuthPayload extends JWTPayload {
sub: string
roles: string[]
email: string
}
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
// 認証が必要なパスのパターン
const protectedPaths = ['/dashboard', '/settings', '/api/protected']
// 認証なしでアクセスできるパス
const publicPaths = ['/login', '/register', '/api/auth']
function isProtectedPath(pathname: string): boolean {
return protectedPaths.some((path) => pathname.startsWith(path))
}
function isPublicPath(pathname: string): boolean {
return publicPaths.some((path) => pathname.startsWith(path))
}
async function verifyToken(token: string): Promise<AuthPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ['HS256'],
clockTolerance: 15, // 15 秒の時計の許容誤差
})
return payload as AuthPayload
} catch (error) {
return null
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 公開パスは通過させる
if (isPublicPath(pathname)) {
return NextResponse.next()
}
// 保護対象のパスでなければ通過させる
if (!isProtectedPath(pathname)) {
return NextResponse.next()
}
const token = request.cookies.get('access_token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
const payload = await verifyToken(token)
if (!payload) {
// トークンが有効でなければリフレッシュを試みる
const refreshToken = request.cookies.get('refresh_token')?.value
if (refreshToken) {
// リフレッシュは Route Handler で処理するためリダイレクトする
const refreshUrl = new URL('/api/auth/refresh', request.url)
refreshUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(refreshUrl)
}
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// 認証済みリクエスト: ユーザー情報をリクエストヘッダーに注入 (任意)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.sub)
requestHeaders.set('x-user-roles', JSON.stringify(payload.roles))
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
}
export const config = {
matcher: [
/*
* 静的ファイルと画像を除いたすべてのリクエストパスにマッチ
* _next/static, _next/image, favicon.ico は除外
*/
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
}
注意点: ミドルウェアでデータベース呼び出しや重い演算を行ってはならない。Edge Runtime は軽量な実行環境であり、高速な応答が必須だ。トークンの署名検証 (jose の jwtVerify) は十分に速いが、トークンのブラックリスト DB 照会のような処理は Route Handler へ委譲するのがよい。
Route Handlerでの認証
Route Handler はクッキーを設定・削除する中心の層だ。ログイン、ログアウト、トークンのリフレッシュ、現在のユーザー情報の取得を実装する。
ログイン(app/api/auth/login/route.ts)
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { SignJWT } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
interface LoginRequest {
email: string
password: string
}
interface BackendAuthResponse {
user: {
id: string
email: string
name: string
roles: string[]
}
accessToken: string
refreshToken: string
}
export async function POST(request: NextRequest) {
try {
const body: LoginRequest = await request.json()
// バックエンドの認証サーバーへログインを要求
const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!backendResponse.ok) {
const error = await backendResponse.json()
return NextResponse.json(
{ error: error.message || 'ログインに失敗しました。' },
{ status: 401 }
)
}
const data: BackendAuthResponse = await backendResponse.json()
// Next.js で独自の JWT を発行するか、バックエンドのトークンをそのまま使う
const response = NextResponse.json({
user: {
id: data.user.id,
email: data.user.email,
name: data.user.name,
roles: data.user.roles,
},
})
// Access Token クッキーの設定
response.cookies.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15, // 15 分
...(IS_PRODUCTION && { domain: '.example.com' }),
})
// Refresh Token クッキーの設定
response.cookies.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh', // リフレッシュのパスでのみ送信
maxAge: 60 * 60 * 24 * 7, // 7 日
...(IS_PRODUCTION && { domain: '.example.com' }),
})
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json({ error: 'サーバー内部エラーが発生しました。' }, { status: 500 })
}
}
ログアウト(app/api/auth/logout/route.ts)
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access_token')?.value
// バックエンドへトークン無効化を要求 (任意: サーバーサイドのブラックリスト)
if (accessToken) {
await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}).catch(() => {
// バックエンド呼び出しが失敗してもクッキーは削除する
})
}
const response = NextResponse.json({ success: true })
// クッキーの削除 — maxAge: 0 で即時に失効させる
response.cookies.set('access_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 0,
})
response.cookies.set('refresh_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 0,
})
return response
} catch (error) {
return NextResponse.json({ error: 'ログアウト処理中にエラーが発生しました。' }, { status: 500 })
}
}
トークンリフレッシュ(app/api/auth/refresh/route.ts)
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')?.value
if (!refreshToken) {
return NextResponse.json({ error: 'Refresh token がありません。' }, { status: 401 })
}
try {
const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!backendResponse.ok) {
// リフレッシュ失敗 → すべてのトークンクッキーを削除
const response = NextResponse.json(
{ error: 'セッションの有効期限が切れました。もう一度ログインしてください。' },
{ status: 401 }
)
response.cookies.set('access_token', '', { path: '/', maxAge: 0 })
response.cookies.set('refresh_token', '', { path: '/api/auth/refresh', maxAge: 0 })
return response
}
const data = await backendResponse.json()
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
const response = NextResponse.json({ success: true })
response.cookies.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
...(IS_PRODUCTION && { domain: '.example.com' }),
})
// Refresh Token Rotation を適用する場合
if (data.refreshToken) {
response.cookies.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 60 * 60 * 24 * 7,
...(IS_PRODUCTION && { domain: '.example.com' }),
})
}
return response
} catch (error) {
return NextResponse.json({ error: 'トークン更新中にエラーが発生しました。' }, { status: 500 })
}
}
現在のユーザー情報(app/api/auth/me/route.ts)
// app/api/auth/me/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function GET(request: NextRequest) {
const token = request.cookies.get('access_token')?.value
if (!token) {
return NextResponse.json({ user: null }, { status: 401 })
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return NextResponse.json({
user: {
id: payload.sub,
email: payload.email,
name: payload.name,
roles: payload.roles,
},
})
} catch (error) {
return NextResponse.json({ user: null }, { status: 401 })
}
}
Server Actionでの認証
Server Action は 'use server' ディレクティブが付いた非同期関数で、フォームの送信とサーバーサイドのデータ変更を処理する。cookies() API へ直接アクセスできるため、認証ロジックをサーバー側で安全に処理できる。
// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
interface LoginFormState {
error?: string
success?: boolean
}
export async function loginAction(
prevState: LoginFormState,
formData: FormData
): Promise<LoginFormState> {
const email = formData.get('email') as string
const password = formData.get('password') as string
if (!email || !password) {
return { error: 'メールアドレスとパスワードを入力してください。' }
}
try {
const response = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const data = await response.json()
return { error: data.message || 'メールアドレスまたはパスワードが正しくありません。' }
}
const data = await response.json()
const cookieStore = await cookies()
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
cookieStore.set('access_token', data.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
})
cookieStore.set('refresh_token', data.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/api/auth/refresh',
maxAge: 60 * 60 * 24 * 7,
})
} catch (error) {
return { error: 'サーバーに接続できません。' }
}
revalidatePath('/')
redirect('/dashboard')
}
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access_token')?.value
// バックエンドへトークン無効化を要求
if (accessToken) {
await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => {})
}
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
revalidatePath('/')
redirect('/login')
}
// app/login/page.tsx — Server Action を使うログインフォーム
'use client'
import { useActionState } from 'react'
import { loginAction } from '@/app/actions/auth'
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(loginAction, {})
return (
<form action={formAction}>
{state.error && <div className="rounded-md bg-red-50 p-3 text-red-600">{state.error}</div>}
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" type="email" required autoComplete="email" />
</div>
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'ログイン中...' : 'ログイン'}
</button>
</form>
)
}
Server Componentでの認証状態アクセス
Server Component は cookies() API を通じて HttpOnly クッキーを直接読めるため、追加の API 呼び出しなしで認証状態を確認できる。
// lib/auth.ts — サーバーサイドの認証ユーティリティ
import { cookies } from 'next/headers'
import { jwtVerify, type JWTPayload } from 'jose'
import { cache } from 'react'
interface User {
id: string
email: string
name: string
roles: string[]
}
interface AuthPayload extends JWTPayload {
sub: string
email: string
name: string
roles: string[]
}
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
// React cache で同一リクエスト内の重複検証を防ぐ
export const getAuthUser = cache(async (): Promise<User | null> => {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (!token) return null
try {
const { payload } = (await jwtVerify(token, JWT_SECRET)) as { payload: AuthPayload }
return {
id: payload.sub,
email: payload.email,
name: payload.name,
roles: payload.roles,
}
} catch (error) {
return null
}
})
export async function requireAuth(): Promise<User> {
const user = await getAuthUser()
if (!user) {
const { redirect } = await import('next/navigation')
redirect('/login')
}
return user
}
// app/dashboard/page.tsx — 認証済みの Server Component
import { requireAuth } from '@/lib/auth'
import { LogoutButton } from '@/components/LogoutButton'
export default async function DashboardPage() {
const user = await requireAuth()
return (
<div>
<header>
<h1>ダッシュボード</h1>
<p>{user.name}さん、ようこそ。</p>
<span className="text-sm text-gray-500">{user.email}</span>
<LogoutButton />
</header>
{user.roles.includes('admin') && (
<section>
<h2>管理者メニュー</h2>
{/* 管理者専用コンテンツ */}
</section>
)}
<section>
<h2>マイ情報</h2>
<p>ロール: {user.roles.join(', ')}</p>
</section>
</div>
)
}
Client Componentでの認証状態管理
Client Component では、サーバーから渡された認証情報を Context で管理するか、API のポーリングで最新の状態を保つ。
// contexts/AuthContext.tsx
'use client'
import { createContext, useContext, useCallback, useMemo, type ReactNode } from 'react'
import useSWR from 'swr'
interface User {
id: string
email: string
name: string
roles: string[]
}
interface AuthContextType {
user: User | null
isLoading: boolean
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
const fetcher = (url: string) =>
fetch(url, { credentials: 'include' }).then((res) => {
if (!res.ok) return { user: null }
return res.json()
})
export function AuthProvider({
children,
initialUser,
}: {
children: ReactNode
initialUser: User | null
}) {
const { data, mutate, isLoading } = useSWR('/api/auth/me', fetcher, {
fallbackData: { user: initialUser },
revalidateOnFocus: true,
revalidateInterval: 5 * 60 * 1000, // 5 分ごとに更新
dedupingInterval: 60 * 1000,
})
const user = data?.user ?? null
const login = useCallback(
async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'ログインに失敗しました。')
}
await mutate()
},
[mutate]
)
const logout = useCallback(async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
await mutate({ user: null }, { revalidate: false })
}, [mutate])
const refresh = useCallback(async () => {
await mutate()
}, [mutate])
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refresh,
}),
[user, isLoading, login, logout, refresh]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth は AuthProvider の内部でのみ使用できます。')
}
return context
}
// app/layout.tsx — Server Component から Client Context へ初期値を渡す
import { getAuthUser } from '@/lib/auth'
import { AuthProvider } from '@/contexts/AuthContext'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getAuthUser()
return (
<html lang="ko">
<body>
<AuthProvider initialUser={user}>{children}</AuthProvider>
</body>
</html>
)
}
ブラウザストレージ別アクセス可能性表
| ストレージ | JSアクセス | サーバー自動送信 | XSS脆弱性 | CSRF脆弱性 | Next.jsサーバーアクセス |
|---|---|---|---|---|---|
| localStorage | O | X | O | X | X |
| sessionStorage | O | X | O | X | X |
| 一般 Cookie | O | O | O | O | O |
| HttpOnly Cookie | X | O | X | O (SameSiteで防御) | O (cookies() API) |
| Authorization Header | O (コード制御) | X (手動) | O (ストレージ依存) | X | X (直接不可) |
Next.js で HttpOnly Cookie が推奨される理由:
- サーバーサイドからアクセスできる:
cookies()API により Server Component、Server Action、Route Handler、ミドルウェアのすべてからアクセスできる。 - XSS への防御: JavaScript からアクセスできないため、トークン窃取のリスクが取り除かれる。
- 自動送信: ブラウザがリクエストのたびに自動でクッキーを含めるため、専用のインターセプタが不要になる。
- CSRF への防御:
SameSite=LaxまたはStrictの設定でクロスサイトリクエストを遮断する。 - SSR との相性: 最初のサーバーレンダリングの時点で認証状態が確定しているため、ちらつき (flash) が起きない。
Cookie設定の実践
開発環境 vs 本番Cookie設定
// lib/cookie-config.ts
export interface CookieConfig {
httpOnly: boolean
secure: boolean
sameSite: 'strict' | 'lax' | 'none'
path: string
maxAge: number
domain?: string
}
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN // .example.com
export const ACCESS_TOKEN_COOKIE: CookieConfig = {
httpOnly: true,
secure: IS_PRODUCTION, // 開発: false (HTTP)、本番: true (HTTPS)
sameSite: IS_PRODUCTION ? 'lax' : 'lax',
path: '/', // すべてのパスで送信
maxAge: 60 * 15, // 15 分
...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}
export const REFRESH_TOKEN_COOKIE: CookieConfig = {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: IS_PRODUCTION ? 'strict' : 'lax',
path: '/api/auth/refresh', // リフレッシュのエンドポイントでのみ送信
maxAge: 60 * 60 * 24 * 7, // 7 日
...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}
// 使用例
// response.cookies.set('access_token', token, ACCESS_TOKEN_COOKIE)
各オプションの意味を整理する。
| オプション | 説明 | 推奨値 |
|---|---|---|
httpOnly | JSアクセスブロック | true (常時) |
secure | HTTPSでのみ送信 | 本番: true、開発: false |
sameSite | クロスサイトリクエスト制御 | lax (一般), strict (リフレッシュ) |
path | Cookie送信パス制限 | Access: /, Refresh: /api/auth/refresh |
maxAge | Cookie有効期間(秒) | Access: 900, Refresh: 604800 |
domain | Cookie有効ドメイン | .example.com (サブドメイン共有時) |
JWTクレームパース
jose ライブラリは Edge Runtime と Node.js の両方で動作し、JWT の検証とクレームの抽出を行う。
// lib/jwt.ts
import { jwtVerify, SignJWT, type JWTPayload } from 'jose'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export interface TokenClaims extends JWTPayload {
sub: string // ユーザー ID
email: string // メールアドレス
name: string // 名前
roles: string[] // ロールの一覧
iat: number // 発行時刻
exp: number // 有効期限
iss: string // 発行者
jti: string // トークン固有 ID (ブラックリスト用)
}
export async function verifyAccessToken(token: string): Promise<TokenClaims> {
const { payload } = await jwtVerify(token, JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
clockTolerance: 15,
})
// 必須クレームの存在を確認
if (!payload.sub || !payload.email) {
throw new Error('必須クレームが欠落しています。')
}
return payload as TokenClaims
}
export async function createAccessToken(user: {
id: string
email: string
name: string
roles: string[]
}): Promise<string> {
return new SignJWT({
email: user.email,
name: user.name,
roles: user.roles,
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('15m')
.setIssuer('https://auth.example.com')
.setJti(crypto.randomUUID())
.sign(JWT_SECRET)
}
// フロントエンドへ渡すフィールドだけを選別
export function sanitizeUserForClient(claims: TokenClaims) {
// 機微な情報 (jti, iat, exp, iss) は除外する
return {
id: claims.sub,
email: claims.email,
name: claims.name,
roles: claims.roles,
}
}
フロントエンドへ渡す原則: JWT に含まれるすべてのクレームをクライアントへ渡してはならない。sub、email、name、roles のような表示用フィールドだけを選び、jti、iss、exp のような内部フィールドはサーバー側でのみ使う。
CORS設定
next.config.jsでのグローバルCORSヘッダー
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// API パスに対する CORS 設定
source: '/api/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: process.env.ALLOWED_ORIGIN || 'https://app.example.com',
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization',
},
{
key: 'Access-Control-Allow-Credentials',
value: 'true',
},
{
key: 'Access-Control-Max-Age',
value: '86400',
},
],
},
]
},
}
module.exports = nextConfig
Route HandlerでのCORS処理
// app/api/auth/login/route.ts (CORS プリフライトへの対応)
import { NextRequest, NextResponse } from 'next/server'
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com']
function getCorsHeaders(origin: string | null) {
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin)
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true',
}
}
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin')
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
})
}
export async function POST(request: NextRequest) {
const origin = request.headers.get('origin')
// ... ログインのロジック ...
const response = NextResponse.json({ success: true })
Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
response.headers.set(key, value)
})
return response
}
ミドルウェアでのCORS処理
同じ CORS ロジックをミドルウェアで一元的に処理することもできる。この場合、各 Route Handler で CORS のコードを繰り返す必要がなくなる。
// middleware.ts 内の CORS 処理ロジック (抜粋)
if (request.method === 'OPTIONS') {
const origin = request.headers.get('origin')
if (origin && ALLOWED_ORIGINS.includes(origin)) {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
},
})
}
}
ログアウトとトークン無効化
サーバーサイドブラックリスト + Cookie削除
単にクッキーを削除するだけでは、すでに窃取されたトークンを無効化できない。完全なログアウトのためには、サーバーサイドのトークンブラックリストが必要だ。
// lib/token-blacklist.ts
// Redis を使ったトークンブラックリストの例
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
export async function blacklistToken(jti: string, expiresAt: number): Promise<void> {
const ttl = expiresAt - Math.floor(Date.now() / 1000)
if (ttl > 0) {
await redis.setex(`blacklist:${jti}`, ttl, '1')
}
}
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
const result = await redis.get(`blacklist:${jti}`)
return result === '1'
}
Server Actionベースのログアウト
// app/actions/auth.ts (logoutAction — ブラックリストを含む)
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { verifyAccessToken } from '@/lib/jwt'
import { blacklistToken } from '@/lib/token-blacklist'
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies()
const token = cookieStore.get('access_token')?.value
if (token) {
try {
const claims = await verifyAccessToken(token)
// jti と exp を使ってブラックリストに追加する
await blacklistToken(claims.jti, claims.exp!)
} catch {
// トークンがすでに期限切れの場合は無視する
}
}
cookieStore.delete('access_token')
cookieStore.delete('refresh_token')
revalidatePath('/', 'layout')
redirect('/login')
}
// components/LogoutButton.tsx
'use client'
import { logoutAction } from '@/app/actions/auth'
export function LogoutButton() {
return (
<form action={logoutAction}>
<button type="submit">ログアウト</button>
</form>
)
}
NextAuth.js / Auth.js比較
自前で実装する場合と NextAuth.js (Auth.js) を使う場合のトレードオフを比較する。
| 項目 | 直接実装 | NextAuth.js / Auth.js |
|---|---|---|
| 柔軟性 | 完全な制御 | フレームワーク規約に依存 |
| 実装コスト | 高い(セキュリティ専門知識が必要) | 低い(クイックスタート) |
| OAuth統合 | 直接実装 | 40+プロバイダー内蔵 |
| セッション管理 | 手動(JWT/DB) | 自動(JWT/DB選択) |
| トークンリフレッシュ | 直接実装 | 内蔵(OAuth限定) |
| SSO連携 | 完全カスタム可能 | 限定的 |
| 学習曲線 | 認証の基礎知識が必要 | NextAuth APIの学習 |
自前で実装するのが適しているケース:
- 既存の認証サーバー (SSO) と統合する必要があるとき
- きめ細かいトークン管理ポリシーが必要なとき
- 複数ドメイン/サービス間でのクッキー共有が必要なとき
- 認証フローを完全に制御する必要があるとき
NextAuth.js が適しているケース:
- Google、GitHub などのソーシャルログインだけが必要なとき
- 素早い MVP 開発が目的のとき
- 認証セキュリティの専門性が足りないとき
セキュリティトレードオフ
XSS(Cross-Site Scripting)
HttpOnly クッキーを使えば document.cookie からトークンを窃取することはできない。しかし XSS の攻撃者が fetch('/api/auth/me') を呼び出してユーザー情報を持ち出したり、認証済みの API を代わりに呼び出したりすることはできる。Server Component でレンダリングされたコンテンツはクライアント JavaScript が介在しないため、XSS により強い。
CSRF(Cross-Site Request Forgery)
SameSite=Lax のクッキーはクロスサイトの POST リクエストにクッキーを含めないため、ほとんどの CSRF 攻撃を遮断する。Server Action は Next.js が自動で CSRF トークンを管理するため、別途の処理は不要だ。
Token Theft & Replay
Access Token の短い有効期限 (15 分) と Refresh Token Rotation を組み合わせれば、トークン窃取による被害を最小化できる。ブラックリストの仕組みを追加すれば、窃取されたトークンを直ちに無効化できる。
Server Componentのセキュリティ上の利点
- Server Component はサーバーでのみ実行されるため、認証ロジックと秘密鍵がクライアントに露出しない。
cookies()API はサーバーからしか呼び出せないため、クライアントによる操作ができない。- データ取得の際にサーバーから直接バックエンドを呼び出すため、トークンがブラウザを経由しない。
チェックリスト
Next.js で認証を実装する際に確認すべき項目を整理する。
- すべての認証クッキーを
httpOnly: trueで設定する - 本番環境に
secure: trueを適用する -
sameSite: 'lax'以上を設定する (CSRF への防御) - Access Token の有効期限を 15 分以下にする
- Refresh Token を専用パス (
/api/auth/refresh) に path で制限する - Refresh Token Rotation を実装する
- ミドルウェアで保護対象パスの認証チェックを行う
- Server Component で
React.cacheにより重複検証を防ぐ - ログアウト時にサーバーサイドのトークンブラックリストを適用する
- Edge Runtime 互換のライブラリ (
jose) を使う - CORS の
credentials: trueを設定する (クロスドメインの場合) - 環境ごとにクッキー設定を分ける (開発/本番)
- JWT クレームのうちフロントエンドへ渡すフィールドを最小化する
- エラーメッセージに内部情報を露出させない
-
callbackUrlを検証する (オープンリダイレクトの防止)
よくあるバグと誤解
1. 「cookies() はどこからでも呼び出せる」
cookies() は Server Component、Server Action、Route Handler からのみ呼び出せる。Client Component ('use client') から呼び出すとビルドエラーになる。
2. 「ミドルウェアで DB を照会してもよい」
ミドルウェアは Edge Runtime で実行され、すべてのリクエストに対して走る。DB 呼び出しは応答遅延の主な原因になる。トークンの署名検証だけを行い、詳細な権限チェックは Route Handler や Server Component で行うべきだ。
3. 「cookies().set() は Server Component から呼び出せる」
cookies().get() は Server Component から呼び出せるが、cookies().set() と cookies().delete() は Server Action または Route Handler からのみ 呼び出せる。Server Component のレンダリング段階ではレスポンスヘッダーを変更できないためだ。
4. 「Edge Runtime で jsonwebtoken ライブラリを使える」
jsonwebtoken は Node.js の crypto モジュールに依存するため、Edge Runtime では動作しない。ミドルウェアでは必ず jose ライブラリを使う必要がある。
5. 「SameSite=Strict が最も安全だから常時 Strict を使うべきだ」
SameSite=Strict は、外部サイトからリンクをクリックして訪問したときにクッキーを送信しない。ソーシャルメディアやメールのリンク経由で来たユーザーが毎回ログインし直さなければならない UX の問題が生じる。Access Token には Lax、Refresh Token には Strict を使うのがバランスの取れた戦略だ。
6. 「Refresh Token も同じ path に設定すればよい」
Refresh Token の path を / に設定すると、すべてのリクエストに不要な Refresh Token が送信される。ネットワーク帯域の無駄に加えて、攻撃面も広がる。/api/auth/refresh にパスを限定し、必要なリクエストでのみ送信されるようにする。
7. 「revalidatePath なしで redirect だけすればよい」
Server Action でクッキーを変更したあと redirect() だけを呼ぶと、キャッシュされたページが以前の認証状態を表示することがある。revalidatePath('/', 'layout') を呼び出して、レイアウトを含むパス全体のキャッシュを無効化する必要がある。
参考資料
- Next.js Authentication 公式ガイド — App Router の認証パターン公式ドキュメント
- Next.js Middleware ドキュメント — ミドルウェアの設定と使い方
- Next.js cookies() API — サーバーサイドのクッキーアクセス API
- Next.js Server Actions — Server Action を活用したデータ変更
- jose ライブラリ GitHub — Edge Runtime 互換の JWT ライブラリ
- RFC 7519 - JSON Web Token — JWT の標準仕様
- RFC 6265 - HTTP State Management (Cookies) — クッキーの標準仕様
- OWASP Session Management Cheat Sheet — セッション管理のセキュリティガイドライン
- NextAuth.js (Auth.js) 公式ドキュメント — Next.js の認証ライブラリ
- Vercel Blog - Understanding Next.js Middleware — ミドルウェアのアーキテクチャ解説
- MDN - SameSite cookies — SameSite クッキー属性の説明
- OWASP Cross-Site Request Forgery Prevention — CSRF 防御ガイド