LabHub

ブログ

Next.js App Router認証実践ガイド — ミドルウェア、Server Action、HttpOnly Cookie、SSR認証完全攻略

한국어English日本語

SSO Cookie/JWT 認証シリーズ > React 編 ← 現在: Next.js 編 → 統合実践編

概要 — Next.js App Router認証の特殊性

Next.js App Router は、従来の React SPA の認証とは根本的に異なるパラダイムを要求する。React 編で扱ったクライアント中心の認証はブラウザの document.cookiefetch リクエストに依存するが、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 SPANext.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 Handlercookies() 読み取り │  │ cookies() 読み取り│  │ cookies() 設定   │
JWT パース        │  │ クッキー設定/削除  │  │ ログイン/ログアウト│
│ 条件付きレンダリング│  │ revalidate       │  │ トークンリフレッシュ│
└──────────────────┘  └──────────────────┘  └──────────────────┘
                   ┌──────────────────┐
Backend API                     (認証サーバー)JWT 発行/検証     │
                   └──────────────────┘

中心となる原則は次のとおり。

  1. ミドルウェアがすべてのリクエストの最初の関門として働き、認証状態を確認する。
  2. Route Handlerがクッキーの設定/削除を担当する (ログイン、ログアウト、リフレッシュ)。
  3. Server Componentはクッキーからトークンを読み、ユーザー情報をレンダリングする。
  4. Server Actionはフォームベースの認証とサーバーサイドのロジックを処理する。
  5. 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サーバーアクセス
localStorageOXOXX
sessionStorageOXOXX
一般 CookieOOOOO
HttpOnly CookieXOXO (SameSiteで防御)O (cookies() API)
Authorization HeaderO (コード制御)X (手動)O (ストレージ依存)XX (直接不可)

Next.js で HttpOnly Cookie が推奨される理由:

  1. サーバーサイドからアクセスできる: cookies() API により Server Component、Server Action、Route Handler、ミドルウェアのすべてからアクセスできる。
  2. XSS への防御: JavaScript からアクセスできないため、トークン窃取のリスクが取り除かれる。
  3. 自動送信: ブラウザがリクエストのたびに自動でクッキーを含めるため、専用のインターセプタが不要になる。
  4. CSRF への防御: SameSite=Lax または Strict の設定でクロスサイトリクエストを遮断する。
  5. 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)

各オプションの意味を整理する。

オプション説明推奨値
httpOnlyJSアクセスブロックtrue (常時)
secureHTTPSでのみ送信本番: true、開発: false
sameSiteクロスサイトリクエスト制御lax (一般), strict (リフレッシュ)
pathCookie送信パス制限Access: /, Refresh: /api/auth/refresh
maxAgeCookie有効期間(秒)Access: 900, Refresh: 604800
domainCookie有効ドメイン.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 に含まれるすべてのクレームをクライアントへ渡してはならない。subemailnameroles のような表示用フィールドだけを選び、jtiissexp のような内部フィールドはサーバー側でのみ使う。

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の学習

自前で実装するのが適しているケース:

NextAuth.js が適しているケース:

セキュリティトレードオフ

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のセキュリティ上の利点

チェックリスト

Next.js で認証を実装する際に確認すべき項目を整理する。

よくあるバグと誤解

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') を呼び出して、レイアウトを含むパス全体のキャッシュを無効化する必要がある。

参考資料

  1. Next.js Authentication 公式ガイド — App Router の認証パターン公式ドキュメント
  2. Next.js Middleware ドキュメント — ミドルウェアの設定と使い方
  3. Next.js cookies() API — サーバーサイドのクッキーアクセス API
  4. Next.js Server Actions — Server Action を活用したデータ変更
  5. jose ライブラリ GitHub — Edge Runtime 互換の JWT ライブラリ
  6. RFC 7519 - JSON Web Token — JWT の標準仕様
  7. RFC 6265 - HTTP State Management (Cookies) — クッキーの標準仕様
  8. OWASP Session Management Cheat Sheet — セッション管理のセキュリティガイドライン
  9. NextAuth.js (Auth.js) 公式ドキュメント — Next.js の認証ライブラリ
  10. Vercel Blog - Understanding Next.js Middleware — ミドルウェアのアーキテクチャ解説
  11. MDN - SameSite cookies — SameSite クッキー属性の説明
  12. OWASP Cross-Site Request Forgery Prevention — CSRF 防御ガイド

コメント

まだコメントはありません。

ログインするとコメントできます