Nuxt 4フルスタック認証実践:OAuth2からPasskeyまでの5つのプロダクションパターン

前端工程

Nuxt 4フルスタック認証実践:OAuth2からPasskeyまでの5つのプロダクションパターン

まだlocalStorageにトークンを保存しているのか?OAuth2ログインフローがスパゲッティコードになっている?Passkeyパスワードレス認証が企画書のまま止まっている?2026年、Nuxt4フルスタック認証エコシステムはプロダクションレディ——OAuth2/OIDCからPasskey/WebAuthnまで、JWTセッションからRBACまで、5つのプロダクションレベル認証パターンでアプリを安全かつ使いやすくする。


背景知識

2026年Web認証技術スタック全体像

次元 従来のアプローチ 2026プロダクションアプローチ
認証 ユーザー名+パスワード OAuth2/OIDC + Passkey
セッション管理 Cookie/LocalStorage JWT + Refresh Token + HttpOnly Cookie
パスワードレス認証 SMS認証 WebAuthn/Passkey
アクセス制御 フロントエンドv-if判定 RBAC + ルートミドルウェア
セキュリティ 基本的なHTTPS CSRFトークン + レート制限 + セキュリティヘッダー
多要素認証 オプション プログレッシブMFA

Nuxt4認証エコシステムコアモジュール

  • Nuxt Auth Utils:公式認証ツールキット、内蔵OAuth2/OIDCプロバイダー
  • h3 Session:Nitro内蔵セッション管理、暗号化署名Cookie対応
  • WebAuthn API:ブラウザネイティブPasskeyサポート、サードパーティSDK不要
  • Route Middleware:Nuxt4強化ルートガード、きめ細かい権限制御対応

問題分析

フルスタック認証の5つのコアペインポイント

  1. OAuth2統合が複雑:プロバイダー設定、コールバック処理、トークンリフレッシュ、エラーリカバリ、各ステップが落とし穴
  2. セッション管理が混乱:JWTをどこに保存?Refresh Tokenのローテーション方法?マルチタブセッション同期?
  3. パスワードレス認証の実装が困難:WebAuthn APIが複雑、登録フロー設計、クロスデバイス同期
  4. 権限制御が粗雑:フロントエンドにハードコードされた権限、未認証API、ロール変更の遅延
  5. プロダクションセキュリティの脆弱性:CSRF攻撃、ブルートフォース、XSSによるトークン窃取、セキュリティヘッダー欠落

ステップバイステップガイド

パターン1:OAuth2/OIDC統合とNuxt Auth Utils

npm install @nuxt/auth-utils
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/auth-utils'],
  runtimeConfig: {
    oauth: {
      github: {
        clientId: '',
        clientSecret: '',
      },
      google: {
        clientId: '',
        clientSecret: '',
      },
    },
  },
})
// server/routes/auth/github.ts
export default defineOAuthGitHubEventHandler({
  config: {
    emailRequired: true,
  },
  async onSuccess(event, { user, tokens }) {
    const existingUser = await findUserByOAuth('github', user.id)

    if (existingUser) {
      await setUserSession(event, {
        user: {
          id: existingUser.id,
          name: existingUser.name,
          email: existingUser.email,
          avatar: user.avatar_url,
          role: existingUser.role,
        },
      })
    } else {
      const newUser = await createUserFromOAuth({
        provider: 'github',
        providerId: String(user.id),
        name: user.name,
        email: user.email,
        avatar: user.avatar_url,
      })

      await setUserSession(event, {
        user: {
          id: newUser.id,
          name: newUser.name,
          email: newUser.email,
          avatar: user.avatar_url,
          role: newUser.role,
        },
      })
    }

    return sendRedirect(event, '/dashboard')
  },
  onError(event, error) {
    console.error('GitHub OAuth error:', error)
    return sendRedirect(event, '/login?error=oauth_failed')
  },
})
// server/routes/auth/google.ts
export default defineOAuthGoogleEventHandler({
  config: {
    scope: ['openid', 'email', 'profile'],
  },
  async onSuccess(event, { user, tokens }) {
    const existingUser = await findUserByOAuth('google', user.sub)

    if (existingUser) {
      await setUserSession(event, {
        user: {
          id: existingUser.id,
          name: existingUser.name,
          email: user.email,
          avatar: user.picture,
          role: existingUser.role,
        },
      })
    } else {
      const newUser = await createUserFromOAuth({
        provider: 'google',
        providerId: user.sub,
        name: user.name,
        email: user.email,
        avatar: user.picture,
      })

      await setUserSession(event, {
        user: {
          id: newUser.id,
          name: newUser.name,
          email: user.email,
          avatar: user.picture,
          role: newUser.role,
        },
      })
    }

    return sendRedirect(event, '/dashboard')
  },
  onError(event, error) {
    console.error('Google OAuth error:', error)
    return sendRedirect(event, '/login?error=oauth_failed')
  },
})
<!-- pages/login.vue -->
<script lang="ts" setup>
const { status, signIn } = useAuth()

const route = useRoute()
const errorMsg = computed(() => {
  const error = route.query.error as string
  if (error === 'oauth_failed') return 'OAuthログインに失敗しました。もう一度お試しください'
  if (error === 'session_expired') return 'セッションが期限切れです。再度ログインしてください'
  return ''
})
</script>

<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-50">
    <div class="w-full max-w-md p-8 bg-white rounded-xl shadow-lg">
      <h1 class="text-2xl font-bold text-center mb-8">ToolsKuにログイン</h1>

      <div v-if="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
        {{ errorMsg }}
      </div>

      <div class="space-y-3">
        <button
          class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
          @click="signIn('github')"
        >
          <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
          GitHubでログイン
        </button>

        <button
          class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
          @click="signIn('google')"
        >
          <svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
          Googleでログイン
        </button>

        <div class="relative my-6">
          <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-200" /></div>
          <div class="relative flex justify-center text-sm"><span class="px-4 bg-white text-gray-500">または</span></div>
        </div>

        <NuxtLink
          to="/login/passkey"
          class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
        >
          <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
          Passkeyでログイン
        </NuxtLink>
      </div>
    </div>
  </div>
</template>

パターン2:JWT + Refresh Tokenセッション管理

// server/utils/jwt.ts
import { sign, verify } from 'hono/jwt'

const JWT_SECRET = process.env.JWT_SECRET!
const REFRESH_SECRET = process.env.REFRESH_SECRET!

interface AccessTokenPayload {
  sub: string
  email: string
  role: string
  type: 'access'
}

interface RefreshTokenPayload {
  sub: string
  tokenVersion: number
  type: 'refresh'
}

export async function signAccessToken(payload: Omit<AccessTokenPayload, 'type' | 'exp' | 'iat'>): Promise<string> {
  return await sign(
    { ...payload, type: 'access', exp: Math.floor(Date.now() / 1000) + 15 * 60 },
    JWT_SECRET
  )
}

export async function signRefreshToken(payload: Omit<RefreshTokenPayload, 'type' | 'exp' | 'iat'>): Promise<string> {
  return await sign(
    { ...payload, type: 'refresh', exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60 },
    REFRESH_SECRET
  )
}

export async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
  const payload = await verify(token, JWT_SECRET)
  if (payload.type !== 'access') throw new Error('Invalid token type')
  return payload as AccessTokenPayload
}

export async function verifyRefreshToken(token: string): Promise<RefreshTokenPayload> {
  const payload = await verify(token, REFRESH_SECRET)
  if (payload.type !== 'refresh') throw new Error('Invalid token type')
  return payload as RefreshTokenPayload
}
// server/utils/session.ts
export async function createAuthSession(event: H3Event, user: { id: string; email: string; role: string }) {
  const accessToken = await signAccessToken({
    sub: user.id,
    email: user.email,
    role: user.role,
  })

  const refreshToken = await signRefreshToken({
    sub: user.id,
    tokenVersion: await getUserTokenVersion(user.id),
  })

  setCookie(event, 'access_token', accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 15 * 60,
    path: '/',
  })

  setCookie(event, 'refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60,
    path: '/api/auth/refresh',
  })

  await setUserSession(event, {
    user: {
      id: user.id,
      email: user.email,
      role: user.role,
    },
  })
}

export async function refreshAuthSession(event: H3Event) {
  const refreshToken = getCookie(event, 'refresh_token')
  if (!refreshToken) throw createError({ statusCode: 401, message: 'No refresh token' })

  const payload = await verifyRefreshToken(refreshToken)
  const currentVersion = await getUserTokenVersion(payload.sub)

  if (payload.tokenVersion !== currentVersion) {
    deleteCookie(event, 'access_token')
    deleteCookie(event, 'refresh_token')
    throw createError({ statusCode: 401, message: 'Token revoked' })
  }

  const user = await findUserById(payload.sub)
  if (!user) throw createError({ statusCode: 401, message: 'User not found' })

  await createAuthSession(event, user)
  return { success: true }
}
// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
  return await refreshAuthSession(event)
})
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)

  if (session?.user?.id) {
    await incrementUserTokenVersion(session.user.id)
  }

  deleteCookie(event, 'access_token', { path: '/' })
  deleteCookie(event, 'refresh_token', { path: '/api/auth/refresh' })
  await clearUserSession(event)

  return { success: true }
})
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/') || event.path.startsWith('/api/auth/')) return

  const accessToken = getCookie(event, 'access_token')

  if (!accessToken) {
    const refreshToken = getCookie(event, 'refresh_token')
    if (refreshToken && event.path !== '/api/auth/refresh') {
      try {
        await refreshAuthSession(event)
        return
      } catch {
        throw createError({ statusCode: 401, message: 'Session expired' })
      }
    }
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  try {
    const payload = await verifyAccessToken(accessToken)
    event.context.auth = payload
  } catch {
    const refreshToken = getCookie(event, 'refresh_token')
    if (refreshToken) {
      try {
        await refreshAuthSession(event)
        return
      } catch {
        throw createError({ statusCode: 401, message: 'Session expired' })
      }
    }
    throw createError({ statusCode: 401, message: 'Invalid token' })
  }
})
// composables/useAuthSession.ts
export function useAuthSession() {
  const user = useUser()
  const status = computed(() => (user.value ? 'authenticated' : 'unauthenticated'))

  async function refresh() {
    try {
      await $fetch('/api/auth/refresh', { method: 'POST' })
    } catch {
      await logout()
    }
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    navigateTo('/login')
  }

  return { user, status, refresh, logout }
}

パターン3:Passkey/WebAuthnパスワードレス認証

// server/utils/webauthn.ts
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'

const RP_NAME = 'ToolsKu'
const RP_ID = process.env.NUXT_PUBLIC_SITE_URL ? new URL(process.env.NUXT_PUBLIC_SITE_URL).hostname : 'localhost'
const ORIGIN = process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'

export async function createRegistrationOptions(userId: string, userEmail: string) {
  const existingCredentials = await getUserCredentials(userId)

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: userId,
    userName: userEmail,
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialId,
      type: 'public-key' as const,
      transports: cred.transports as AuthenticatorTransport[],
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
      authenticatorAttachment: 'platform',
    },
  })

  await storeChallenge(userId, options.challenge)
  return options
}

export async function verifyRegistration(userId: string, response: RegistrationResponseJSON) {
  const challenge = await getChallenge(userId)
  if (!challenge) throw new Error('Challenge expired')

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
  })

  if (verification.verified && verification.registrationInfo) {
    await saveCredential(userId, {
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter,
      credentialDeviceType: verification.registrationInfo.credentialDeviceType,
      credentialBackedUp: verification.registrationInfo.credentialBackedUp,
      transports: response.response.transports,
    })
  }

  await deleteChallenge(userId)
  return verification
}

export async function createAuthenticationOptions(userId?: string) {
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials: userId
      ? (await getUserCredentials(userId)).map(cred => ({
          id: cred.credentialId,
          type: 'public-key' as const,
          transports: cred.transports as AuthenticatorTransport[],
        }))
      : undefined,
    userVerification: 'preferred',
  })

  if (userId) {
    await storeChallenge(userId, options.challenge)
  } else {
    await storeChallenge('anonymous', options.challenge)
  }

  return options
}

export async function verifyAuthentication(response: AuthenticationResponseJSON) {
  const credential = await findCredentialById(response.id)
  if (!credential) throw new Error('Credential not found')

  const challenge = await getChallenge(credential.userId)
  if (!challenge) throw new Error('Challenge expired')

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
    },
  })

  if (verification.verified) {
    await updateCredentialCounter(credential.credentialId, verification.authenticationInfo.newCounter)
  }

  await deleteChallenge(credential.userId)
  return { verified: verification.verified, userId: credential.userId }
}
// server/api/auth/passkey/register-options.post.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  if (!session?.user?.id) {
    throw createError({ statusCode: 401, message: 'Login required' })
  }

  const options = await createRegistrationOptions(session.user.id, session.user.email)
  return options
})
// server/api/auth/passkey/register-verify.post.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  if (!session?.user?.id) {
    throw createError({ statusCode: 401, message: 'Login required' })
  }

  const body = await readBody(event)
  const verification = await verifyRegistration(session.user.id, body)

  return { verified: verification.verified }
})
// server/api/auth/passkey/authenticate-options.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const options = await createAuthenticationOptions(body?.userId)
  return options
})
// server/api/auth/passkey/authenticate-verify.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const result = await verifyAuthentication(body)

  if (result.verified) {
    const user = await findUserById(result.userId)
    if (user) {
      await createAuthSession(event, user)
    }
  }

  return { verified: result.verified }
})
<!-- composables/usePasskey.ts -->
<script lang="ts">
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'

export function usePasskey() {
  const isSupported = ref(false)
  const isRegistering = ref(false)
  const isAuthenticating = ref(false)

  onMounted(() => {
    isSupported.value = window.PublicKeyCredential !== undefined
  })

  async function register() {
    if (!isSupported.value || isRegistering.value) return
    isRegistering.value = true

    try {
      const options = await $fetch<PublicKeyCredentialCreationOptionsJSON>('/api/auth/passkey/register-options', {
        method: 'POST',
      })

      const credential = await startRegistration({ optionsJSON: options })

      const result = await $fetch('/api/auth/passkey/register-verify', {
        method: 'POST',
        body: credential,
      })

      return result.verified
    } catch (error) {
      if (error instanceof Error && error.name === 'InvalidStateError') {
        console.warn('Passkey already registered')
      }
      throw error
    } finally {
      isRegistering.value = false
    }
  }

  async function authenticate() {
    if (!isSupported.value || isAuthenticating.value) return
    isAuthenticating.value = true

    try {
      const options = await $fetch<PublicKeyCredentialRequestOptionsJSON>('/api/auth/passkey/authenticate-options', {
        method: 'POST',
      })

      const credential = await startAuthentication({ optionsJSON: options })

      const result = await $fetch('/api/auth/passkey/authenticate-verify', {
        method: 'POST',
        body: credential,
      })

      return result.verified
    } catch (error) {
      throw error
    } finally {
      isAuthenticating.value = false
    }
  }

  return { isSupported, isRegistering, isAuthenticating, register, authenticate }
}
</script>
<!-- pages/login/passkey.vue -->
<script lang="ts" setup>
const { isSupported, isAuthenticating, authenticate } = usePasskey()
const errorMessage = ref('')

async function handlePasskeyLogin() {
  errorMessage.value = ''
  try {
    const verified = await authenticate()
    if (verified) {
      navigateTo('/dashboard')
    }
  } catch (error) {
    errorMessage.value = 'Passkeyログインに失敗しました。もう一度お試しください'
  }
}
</script>

<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-50">
    <div class="w-full max-w-md p-8 bg-white rounded-xl shadow-lg">
      <h1 class="text-2xl font-bold text-center mb-6">Passkeyパスワードレスログイン</h1>

      <div v-if="!isSupported" class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
        お使いのブラウザはPasskeyをサポートしていません。最新版のChrome/Safari/Edgeをご使用ください。
      </div>

      <div v-if="errorMessage" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
        {{ errorMessage }}
      </div>

      <button
        :disabled="!isSupported || isAuthenticating"
        class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
        @click="handlePasskeyLogin"
      >
        <svg v-if="!isAuthenticating" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
        <svg v-else class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
        {{ isAuthenticating ? '検証中...' : 'Passkeyでログイン' }}
      </button>

      <div class="mt-6 text-center">
        <NuxtLink to="/login" class="text-sm text-indigo-600 hover:text-indigo-500">他のログイン方法に戻る</NuxtLink>
      </div>
    </div>
  </div>
</template>

パターン4:RBAC権限制御とルートミドルウェア

// server/utils/rbac.ts
export type Role = 'superadmin' | 'admin' | 'editor' | 'viewer'

export const PERMISSIONS = {
  superadmin: ['*'],
  admin: ['users:read', 'users:write', 'users:delete', 'content:read', 'content:write', 'content:delete', 'settings:read', 'settings:write'],
  editor: ['content:read', 'content:write', 'settings:read'],
  viewer: ['content:read'],
} as const

export type Permission = typeof PERMISSIONS[Role][number]

export function hasPermission(role: Role, permission: string): boolean {
  const perms = PERMISSIONS[role]
  if (!perms) return false
  if (perms.includes('*' as any)) return true
  return (perms as readonly string[]).includes(permission)
}

export function hasAnyPermission(role: Role, permissions: string[]): boolean {
  return permissions.some(p => hasPermission(role, p))
}

export function hasAllPermissions(role: Role, permissions: string[]): boolean {
  return permissions.every(p => hasPermission(role, p))
}
// server/middleware/rbac.ts
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) return

  const role = auth.role as Role
  const path = event.path
  const method = event.method

  const routePermissions: Record<string, { methods: string[]; permission: string }[]> = {
    '/api/users': [
      { methods: ['GET'], permission: 'users:read' },
      { methods: ['POST', 'PUT', 'PATCH'], permission: 'users:write' },
      { methods: ['DELETE'], permission: 'users:delete' },
    ],
    '/api/content': [
      { methods: ['GET'], permission: 'content:read' },
      { methods: ['POST', 'PUT', 'PATCH'], permission: 'content:write' },
      { methods: ['DELETE'], permission: 'content:delete' },
    ],
    '/api/settings': [
      { methods: ['GET'], permission: 'settings:read' },
      { methods: ['POST', 'PUT', 'PATCH'], permission: 'settings:write' },
    ],
  }

  for (const [routePath, rules] of Object.entries(routePermissions)) {
    if (!path.startsWith(routePath)) continue

    const matchedRule = rules.find(rule => rule.methods.includes(method))
    if (matchedRule && !hasPermission(role, matchedRule.permission)) {
      throw createError({ statusCode: 403, message: 'Insufficient permissions' })
    }
  }
})
// middleware/role.global.ts
export default defineNuxtRouteMiddleware((to) => {
  const { data: session } = useNuxtApp().$auth

  if (!session.value?.user) {
    return navigateTo('/login')
  }

  const role = session.value.user.role as Role
  const routeRoles = to.meta.roles as Role[] | undefined

  if (routeRoles && !routeRoles.includes(role)) {
    return navigateTo('/403')
  }
})
// pages/dashboard/admin.vue
definePageMeta({
  roles: ['superadmin', 'admin'],
})
// pages/dashboard/editor.vue
definePageMeta({
  roles: ['superadmin', 'admin', 'editor'],
})
<!-- components/PermissionGuard.vue -->
<script lang="ts" setup>
const props = defineProps<{
  permission: string
  fallback?: boolean
}>()

const { data: session } = useNuxtApp().$auth
const hasAccess = computed(() => {
  if (!session.value?.user?.role) return false
  return hasPermission(session.value.user.role as Role, props.permission)
})
</script>

<template>
  <slot v-if="hasAccess" />
  <slot v-else name="fallback" />
</template>
<!-- 使用例 -->
<template>
  <div>
    <h1>コンテンツ管理</h1>

    <PermissionGuard permission="content:write">
      <button class="px-4 py-2 bg-indigo-600 text-white rounded-lg">コンテンツ作成</button>
    </PermissionGuard>

    <PermissionGuard permission="content:delete" :fallback="false">
      <button class="px-4 py-2 bg-red-600 text-white rounded-lg">コンテンツ削除</button>
      <template #fallback>
        <span class="text-gray-400 text-sm">削除権限がありません</span>
      </template>
    </PermissionGuard>
  </div>
</template>

パターン5:プロダクションセキュリティ(CSRF、レート制限、セキュリティヘッダー)

// server/middleware/csrf.ts
const CSRF_WHITELIST = ['/api/auth/github', '/api/auth/google', '/api/auth/passkey']

export default defineEventHandler(async (event) => {
  if (event.method === 'GET' || event.method === 'HEAD' || event.method === 'OPTIONS') return
  if (!event.path.startsWith('/api/')) return
  if (CSRF_WHITELIST.some(path => event.path.startsWith(path))) return

  const origin = getRequestHeader(event, 'origin') || getRequestHeader(event, 'referer')
  const host = process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'

  if (!origin || !origin.startsWith(host)) {
    throw createError({ statusCode: 403, message: 'CSRF check failed' })
  }
})
// server/middleware/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()

export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/auth/')) return

  const clientIp = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
  const key = `rate:${clientIp}:${event.path}`
  const now = Date.now()
  const windowMs = 60 * 1000
  const maxRequests = event.path.includes('/passkey') ? 10 : 30

  const record = rateLimitMap.get(key)

  if (!record || now > record.resetAt) {
    rateLimitMap.set(key, { count: 1, resetAt: now + windowMs })
    return
  }

  record.count++

  if (record.count > maxRequests) {
    setResponseHeader(event, 'Retry-After', String(Math.ceil((record.resetAt - now) / 1000)))
    throw createError({ statusCode: 429, message: 'Too many requests' })
  }
})
// server/middleware/security-headers.ts
export default defineEventHandler((event) => {
  setResponseHeader(event, 'X-Content-Type-Options', 'nosniff')
  setResponseHeader(event, 'X-Frame-Options', 'DENY')
  setResponseHeader(event, 'X-XSS-Protection', '0')
  setResponseHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
  setResponseHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
  setResponseHeader(event, 'Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.github.com https://accounts.google.com",
    "frame-ancestors 'none'",
  ].join('; '))
})
// nuxt.config.ts - セキュリティ設定まとめ
export default defineNuxtConfig({
  modules: ['@nuxt/auth-utils'],
  runtimeConfig: {
    jwtSecret: '',
    refreshSecret: '',
    oauth: {
      github: { clientId: '', clientSecret: '' },
      google: { clientId: '', clientSecret: '' },
    },
  },
  nitro: {
    compressPublicAssets: true,
  },
  routeRules: {
    '/api/auth/**': { cors: false },
    '/api/**': { headers: { 'Cache-Control': 'no-store' } },
  },
})

完全コード:プロダクションレベル認証システム

// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })

  if (!hasPermission(auth.role as Role, 'users:read')) {
    throw createError({ statusCode: 403, message: 'Forbidden' })
  }

  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Number(query.limit) || 20

  const users = await db.user.findMany({
    select: { id: true, name: true, email: true, role: true, createdAt: true },
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
  })

  const total = await db.user.count()

  return { users, pagination: { page, limit, total } }
})
// server/api/users/[id].patch.ts
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })

  if (!hasPermission(auth.role as Role, 'users:write')) {
    throw createError({ statusCode: 403, message: 'Forbidden' })
  }

  const userId = getRouterParam(event, 'id')!
  const body = await readBody(event)

  const allowedFields = ['name', 'email', 'role']
  const updateData: Record<string, any> = {}

  for (const field of allowedFields) {
    if (body[field] !== undefined) {
      updateData[field] = body[field]
    }
  }

  if (updateData.role && auth.role !== 'superadmin' && auth.role !== 'admin') {
    delete updateData.role
  }

  const updated = await db.user.update({
    where: { id: userId },
    data: updateData,
    select: { id: true, name: true, email: true, role: true },
  })

  return updated
})
// server/api/users/[id]/credentials.get.ts
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })

  const userId = getRouterParam(event, 'id')!
  if (auth.sub !== userId && !hasPermission(auth.role as Role, 'users:read')) {
    throw createError({ statusCode: 403, message: 'Forbidden' })
  }

  const credentials = await db.credential.findMany({
    where: { userId },
    select: {
      id: true,
      credentialDeviceType: true,
      createdAt: true,
      lastUsedAt: true,
    },
  })

  return credentials
})
<!-- pages/dashboard/index.vue -->
<script lang="ts" setup>
definePageMeta({
  middleware: 'role',
  roles: ['superadmin', 'admin', 'editor', 'viewer'],
})

const { data: session } = useNuxtApp().$auth
const { register: registerPasskey, isSupported: passkeySupported } = usePasskey()

async function handleRegisterPasskey() {
  try {
    const verified = await registerPasskey()
    if (verified) {
      useToast().add({ title: 'Passkey登録成功', color: 'green' })
    }
  } catch {
    useToast().add({ title: 'Passkey登録失敗', color: 'red' })
  }
}
</script>

<template>
  <div class="p-6 max-w-4xl mx-auto">
    <div class="flex items-center justify-between mb-8">
      <div>
        <h1 class="text-2xl font-bold">おかえりなさい、{{ session?.user?.name }}</h1>
        <p class="text-gray-500 mt-1">ロール:{{ session?.user?.role }}</p>
      </div>

      <button
        v-if="passkeySupported"
        class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm"
        @click="handleRegisterPasskey"
      >
        Passkeyを登録
      </button>
    </div>

    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
      <PermissionGuard permission="users:read">
        <NuxtLink to="/dashboard/users" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
          <h3 class="font-semibold text-lg">ユーザー管理</h3>
          <p class="text-gray-500 text-sm mt-1">ユーザーアカウントと権限の管理</p>
        </NuxtLink>
      </PermissionGuard>

      <PermissionGuard permission="content:read">
        <NuxtLink to="/dashboard/content" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
          <h3 class="font-semibold text-lg">コンテンツ管理</h3>
          <p class="text-gray-500 text-sm mt-1">コンテンツの編集と公開</p>
        </NuxtLink>
      </PermissionGuard>

      <PermissionGuard permission="settings:read">
        <NuxtLink to="/dashboard/settings" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
          <h3 class="font-semibold text-lg">システム設定</h3>
          <p class="text-gray-500 text-sm mt-1">システムパラメータの設定</p>
        </NuxtLink>
      </PermissionGuard>
    </div>
  </div>
</template>

落とし穴ガイド

落とし穴1:SSRモードでOAuth2コールバックが2回実行される

現象:OAuth2プロバイダーのコールバック後、サーバーとクライアントでセッション設定がそれぞれ実行され、セッションが不一致になる。

解決:OAuth2コールバックルートはサーバー側でのみ実行する。sendRedirectでターゲットページに遷移し、コールバックルートからVueコンポーネントを返さない。コールバックURLはrouteRulesで純粋なAPIルートとして設定する。

落とし穴2:localStorageのJWTがXSSで窃取される

現象:Access TokenをlocalStorageに保存していると、XSS脆弱性が存在する場合、Tokenが即座に窃取される。

解決:Access TokenとRefresh TokenはどちらもHttpOnly Cookieに保存する。フロントエンドは/api/auth/sessionエンドポイント経由でユーザー情報を取得し、Tokenを直接読み取らない。CookieにはSameSite=Laxを設定してCSRFを防止する。

落とし穴3:Passkey登録時のChallenge期限切れ

現象:Passkey登録フロー中にユーザーが長時間操作せず、Challengeが期限切れになって検証に失敗する。

解決:Challengeの有効期間を5分に設定する。登録ページにタイムアウト通知を追加する。検証に失敗した場合、自動的にChallengeを再取得し、ユーザーにリトライを促す。

落とし穴4:RBACミドルウェアの実行順序エラー

現象:RBAC権限ミドルウェアがAuthミドルウェアより先に実行され、event.context.authがundefinedになり500エラーが発生する。

解決server/middleware/のファイル名で実行順序を保証する。Authミドルウェアを1.auth.ts、RBACミドルウェアを2.rbac.tsと名付ける。Nitroはファイル名の辞書順でミドルウェアを実行する。

落とし穴5:Edge Runtimeでのレート制限メモリリーク

現象:インメモリMapでレート制限を実装すると、Cloudflare Workersにデプロイ後メモリが増加し続ける。

解決:プロダクション環境ではCloudflare KVまたはRedisをレート制限カウンターのストレージとして使用する。インメモリMapは開発環境のみで使用する。期限切れレコードの定期クリーンアップを実装する。


エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 OAuth2 state mismatch コールバックのstateパラメータが不一致 Cookieがクリアされていないか確認、SameSite設定が正しいか確認
2 jwt malformed トークン形式エラー トークンが完全か確認、URLエンコードで切り詰められていないか確認
3 jwt expired Access Tokenの有効期限切れ Refresh Tokenローテーションをトリガー、時計同期を確認
4 Token revoked Token Versionの不一致 ユーザーがパスワードを変更、または管理者がTokenを取り消し
5 WebAuthn is not supported ブラウザがPasskeyをサポートしていない OAuth2ログインにフォールバック、ブラウザのアップグレードを促す
6 Challenge expired Passkey Challengeの期限切れ 登録/認証オプションを再取得
7 Credential not found Passkeyクレデンシャルが存在しない ユーザーがPasskeyを未登録、または削除済み
8 CSRF check failed Origin/Referer検証失敗 リクエスト元を確認、OAuthコールバックをホワイトリストに追加
9 429 Too Many Requests リクエストレート超過 クールダウン待ち、異常リクエストの確認
10 Insufficient permissions RBAC権限不足 ユーザーロールと必要権限設定を確認

高度な最適化

1. マルチタブセッション同期

// composables/useSessionSync.ts
export function useSessionSync() {
  const channel = ref<BroadcastChannel | null>(null)

  onMounted(() => {
    if (typeof BroadcastChannel !== 'undefined') {
      channel.value = new BroadcastChannel('auth-session')

      channel.value.onmessage = (event) => {
        if (event.data.type === 'session-updated') {
          refreshNuxtApp()
        }
        if (event.data.type === 'session-cleared') {
          navigateTo('/login')
        }
      }
    }
  })

  function notifySessionUpdate(type: 'session-updated' | 'session-cleared') {
    channel.value?.postMessage({ type })
  }

  onUnmounted(() => {
    channel.value?.close()
  })

  return { notifySessionUpdate }
}

2. プログレッシブMFA

// server/middleware/mfa.ts
const MFA_REQUIRED_ROLES: Role[] = ['admin', 'superadmin']
const SENSITIVE_PATHS = ['/api/users', '/api/settings']

export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) return

  const requiresMfa = MFA_REQUIRED_ROLES.includes(auth.role as Role)
    && SENSITIVE_PATHS.some(p => event.path.startsWith(p))

  if (!requiresMfa) return

  const mfaVerified = getCookie(event, 'mfa_verified')
  if (!mfaVerified) {
    throw createError({ statusCode: 403, message: 'MFA required' })
  }
})

3. 自動トークンリフレッシュ

// composables/useAutoRefresh.ts
export function useAutoRefresh() {
  const REFRESH_INTERVAL = 14 * 60 * 1000

  let timer: ReturnType<typeof setInterval> | null = null

  onMounted(() => {
    timer = setInterval(async () => {
      try {
        await $fetch('/api/auth/refresh', { method: 'POST' })
      } catch {
        clearInterval(timer!)
        navigateTo('/login')
      }
    }, REFRESH_INTERVAL)
  })

  onUnmounted(() => {
    if (timer) clearInterval(timer)
  })
}

4. 監査ログ

// server/utils/audit.ts
export async function auditLog(event: {
  userId: string
  action: string
  resource: string
  resourceId?: string
  ip: string
  userAgent: string
  status: 'success' | 'failure'
}) {
  await db.auditLog.create({ data: event })
}

// server/api/users/[id].patch.tsでの使用
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  const userId = getRouterParam(event, 'id')!

  try {
    const result = await updateUser(userId, await readBody(event))

    await auditLog({
      userId: auth.sub,
      action: 'update',
      resource: 'user',
      resourceId: userId,
      ip: getRequestIP(event, { xForwardedFor: true }) || 'unknown',
      userAgent: getRequestHeader(event, 'user-agent') || 'unknown',
      status: 'success',
    })

    return result
  } catch (error) {
    await auditLog({
      userId: auth.sub,
      action: 'update',
      resource: 'user',
      resourceId: userId,
      ip: getRequestIP(event, { xForwardedFor: true }) || 'unknown',
      userAgent: getRequestHeader(event, 'user-agent') || 'unknown',
      status: 'failure',
    })
    throw error
  }
})

比較分析

次元 Nuxt Auth Utils NextAuth.js v5 Lucia Supabase Auth
OAuth2統合 内蔵マルチプロバイダー 内蔵マルチプロバイダー 手動 内蔵
セッション管理 h3 Session暗号化 JWT/Database Database JWT
Passkeyサポート @simplewebauthn必要 実験的 手動 内蔵
RBAC 自前構築 内蔵Callbacks 自前構築 内蔵RLS
セルフホスト 完全セルフホスト 完全セルフホスト 完全セルフホスト Supabase必要
学習曲線
Nuxt4互換性 ネイティブ 非対応 アダプタ必要 アダプタ必要
プロダクション対応 2026年成熟 成熟 成熟 成熟

まとめと展望

まとめ:Nuxt4フルスタック認証の5つのプロダクションパターン——OAuth2/OIDC統合がサードパーティログインを解決、JWT + Refresh Tokenが安全なセッション管理を実現、Passkey/WebAuthnがパスワードレス認証のトレンドをリード、RBACミドルウェアがきめ細かい権限制御を実装、プロダクションセキュリティ強化が一般的な脆弱性を塞ぐ。コア原則:トークンはHttpOnly Cookieに保存、ミドルウェアは順序通りに実行、Passkeyフォールバックは不可欠、レート制限には外部ストレージを使用、監査ログですべてをカバー。新規プロジェクトではNuxt Auth Utils + h3 Session + Passkeyのトリオを直接採用。既存プロジェクトではセッションストレージ方式を段階的に移行。


オンラインツールおすすめ

ブラウザローカルツールを無料で試す →

#Nuxt4认证#OAuth2#Passkey#JWT#Session管理#2026#前端工程