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つのコアペインポイント
- OAuth2統合が複雑:プロバイダー設定、コールバック処理、トークンリフレッシュ、エラーリカバリ、各ステップが落とし穴
- セッション管理が混乱:JWTをどこに保存?Refresh Tokenのローテーション方法?マルチタブセッション同期?
- パスワードレス認証の実装が困難:WebAuthn APIが複雑、登録フロー設計、クロスデバイス同期
- 権限制御が粗雑:フロントエンドにハードコードされた権限、未認証API、ロール変更の遅延
- プロダクションセキュリティの脆弱性: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のトリオを直接採用。既存プロジェクトではセッションストレージ方式を段階的に移行。
オンラインツールおすすめ
- JSONフォーマッター(JWTデバッグ):/ja/json/format
- ハッシュ計算(パスワードハッシュ):/ja/encode/hash
- Base64エンコーダー/デコーダー(Token解析):/ja/encode/base64
ブラウザローカルツールを無料で試す →