Nuxt 4 Full-Stack Authentication: 5 Production Patterns from OAuth2 to Passkey

前端工程

Nuxt 4 Full-Stack Authentication: 5 Production Patterns from OAuth2 to Passkey

Still storing tokens in localStorage? Your OAuth2 login flow looks like spaghetti code? Passkey passwordless authentication stuck on the whiteboard? In 2026, the Nuxt4 full-stack authentication ecosystem is production-ready — from OAuth2/OIDC to Passkey/WebAuthn, from JWT sessions to RBAC, 5 production-grade authentication patterns that make your app both secure and user-friendly.


Background

2026 Web Authentication Technology Landscape

Dimension Traditional Approach 2026 Production Approach
Authentication Username + Password OAuth2/OIDC + Passkey
Session Management Cookie/LocalStorage JWT + Refresh Token + HttpOnly Cookie
Passwordless Auth SMS Verification WebAuthn/Passkey
Access Control Frontend v-if checks RBAC + Route Middleware
Security Basic HTTPS CSRF Token + Rate Limit + Security Headers
Multi-Factor Auth Optional Progressive MFA

Nuxt4 Authentication Ecosystem Core Modules

  • Nuxt Auth Utils: Official authentication toolkit with built-in OAuth2/OIDC Providers
  • h3 Session: Nitro's built-in session management with encrypted signed cookies
  • WebAuthn API: Browser-native Passkey support, no third-party SDK required
  • Route Middleware: Nuxt4 enhanced route guards with fine-grained permission control

Problem Analysis

5 Core Pain Points of Full-Stack Authentication

  1. Complex OAuth2 Integration: Provider configuration, callback handling, token refresh, error recovery — every step is a potential pitfall
  2. Chaotic Session Management: Where to store JWT? How to rotate Refresh Tokens? Multi-tab session synchronization?
  3. Difficult Passwordless Implementation: Complex WebAuthn API, registration flow design, cross-device sync
  4. Coarse Permission Control: Hardcoded frontend permissions, unauthenticated APIs, delayed role changes
  5. Production Security Vulnerabilities: CSRF attacks, brute force, XSS token theft, missing security headers

Step-by-Step Guide

Pattern 1: OAuth2/OIDC Integration with 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 login failed, please try again'
  if (error === 'session_expired') return 'Session expired, please log in again'
  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">Sign in to 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>
          Sign in with 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>
          Sign in with 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">or</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>
          Sign in with Passkey
        </NuxtLink>
      </div>
    </div>
  </div>
</template>

Pattern 2: JWT + Refresh Token Session Management

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

const JWT_SECRET = process.env.JWT_SECRET!
const REFRESH_SECRET = process.env.REFRESH_SECRET!
const ACCESS_TOKEN_TTL = '15m'
const REFRESH_TOKEN_TTL = '7d'

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 }
}

Pattern 3: Passkey/WebAuthn Passwordless Authentication

// 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 login failed, please try again'
  }
}
</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 Passwordless Login</h1>

      <div v-if="!isSupported" class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
        Your browser does not support Passkey. Please use the latest 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 ? 'Verifying...' : 'Sign in with Passkey' }}
      </button>

      <div class="mt-6 text-center">
        <NuxtLink to="/login" class="text-sm text-indigo-600 hover:text-indigo-500">Back to other login methods</NuxtLink>
      </div>
    </div>
  </div>
</template>

Pattern 4: Role-Based Access Control (RBAC) with Route Middleware

// 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>
<!-- Usage Example -->
<template>
  <div>
    <h1>Content Management</h1>

    <PermissionGuard permission="content:write">
      <button class="px-4 py-2 bg-indigo-600 text-white rounded-lg">Create Content</button>
    </PermissionGuard>

    <PermissionGuard permission="content:delete" :fallback="false">
      <button class="px-4 py-2 bg-red-600 text-white rounded-lg">Delete Content</button>
      <template #fallback>
        <span class="text-gray-400 text-sm">No delete permission</span>
      </template>
    </PermissionGuard>
  </div>
</template>

Pattern 5: Production Security (CSRF, Rate Limiting, Security Headers)

// 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 - Security Configuration Summary
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' } },
  },
})

Complete Code: Production-Grade Authentication System

// 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 registered successfully', color: 'green' })
    }
  } catch {
    useToast().add({ title: 'Passkey registration failed', 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">Welcome back, {{ session?.user?.name }}</h1>
        <p class="text-gray-500 mt-1">Role: {{ 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"
      >
        Register 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">User Management</h3>
          <p class="text-gray-500 text-sm mt-1">Manage user accounts and permissions</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">Content Management</h3>
          <p class="text-gray-500 text-sm mt-1">Edit and publish content</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">System Settings</h3>
          <p class="text-gray-500 text-sm mt-1">Configure system parameters</p>
        </NuxtLink>
      </PermissionGuard>
    </div>
  </div>
</template>

Pitfall Guide

Pitfall 1: OAuth2 Callback Executed Twice in SSR Mode

Symptom: After the OAuth2 Provider callback, the session is set on both server and client, causing inconsistent sessions.

Solution: OAuth2 callback routes should only execute on the server. Use sendRedirect to navigate to the target page — do not return Vue components from callback routes. Ensure the callback URL is configured as a pure API route in routeRules.

Pitfall 2: JWT in localStorage Stolen via XSS

Symptom: Access Token stored in localStorage is immediately stolen if any XSS vulnerability exists.

Solution: Both Access Token and Refresh Token should be stored in HttpOnly Cookies. The frontend retrieves user info via the /api/auth/session endpoint instead of directly reading the Token. Set SameSite=Lax on cookies to prevent CSRF.

Pitfall 3: Passkey Registration Challenge Expiration

Symptom: The user takes too long during the Passkey registration flow, and the Challenge expires by the time they submit.

Solution: Set the Challenge validity period to 5 minutes. Add a timeout prompt on the registration page. If verification fails, automatically re-fetch the Challenge and prompt the user to retry.

Pitfall 4: RBAC Middleware Execution Order Error

Symptom: The RBAC permission middleware executes before the Auth middleware, causing event.context.auth to be undefined and resulting in a 500 error.

Solution: Ensure file naming in server/middleware/ guarantees execution order. Name the Auth middleware 1.auth.ts and the RBAC middleware 2.rbac.ts — Nitro executes middleware in dictionary order by filename.

Pitfall 5: Rate Limit Memory Leak in Edge Runtime

Symptom: Using an in-memory Map for Rate Limiting causes continuous memory growth when deployed to Cloudflare Workers.

Solution: In production, use Cloudflare KV or Redis for Rate Limit counters. In-memory Maps should only be used in development. Implement periodic cleanup of expired records.


Error Troubleshooting

# Error Message Cause Solution
1 OAuth2 state mismatch Callback state parameter mismatch Check if cookies were cleared, ensure SameSite is configured correctly
2 jwt malformed Token format error Check if the Token is complete and not truncated by URL encoding
3 jwt expired Access Token expired Trigger Refresh Token rotation, check clock synchronization
4 Token revoked Token Version mismatch User changed password or admin revoked Token
5 WebAuthn is not supported Browser does not support Passkey Fallback to OAuth2 login, prompt user to upgrade browser
6 Challenge expired Passkey Challenge expired Re-fetch registration/authentication options
7 Credential not found Passkey credential does not exist User has not registered a Passkey or deleted it
8 CSRF check failed Origin/Referer validation failed Check request origin, add OAuth callbacks to whitelist
9 429 Too Many Requests Request rate exceeded Wait for cooldown, check for abnormal requests
10 Insufficient permissions RBAC permission insufficient Check user role and required permission configuration

Advanced Optimization

1. Multi-Tab Session Synchronization

// 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. Progressive 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. Automatic Token Refresh

// 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. Audit Logging

// 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 })
}

// Usage in 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
  }
})

Comparison Analysis

Dimension Nuxt Auth Utils NextAuth.js v5 Lucia Supabase Auth
OAuth2 Integration Built-in multi-provider Built-in multi-provider Manual Built-in
Session Management h3 Session encrypted JWT/Database Database JWT
Passkey Support Requires @simplewebauthn Experimental Manual Built-in
RBAC Self-built Built-in Callbacks Self-built Built-in RLS
Self-hosted Fully self-hosted Fully self-hosted Fully self-hosted Requires Supabase
Learning Curve Low Medium Medium Low
Nuxt4 Compatibility Native Not applicable Requires adaptation Requires adaptation
Production Ready Mature in 2026 Mature Mature Mature

Summary & Outlook

Summary: The 5 production patterns for Nuxt4 full-stack authentication — OAuth2/OIDC integration solves third-party login, JWT + Refresh Token enables secure session management, Passkey/WebAuthn leads the passwordless authentication trend, RBAC middleware implements fine-grained permission control, and production security hardening plugs common vulnerabilities. Core principles: store tokens in HttpOnly Cookies, execute middleware in order, never skip Passkey fallback, use external storage for Rate Limiting, and cover everything with audit logs. For new projects, adopt the Nuxt Auth Utils + h3 Session + Passkey trio directly. For existing projects, gradually migrate session storage methods.


Try these browser-local tools — no sign-up required →

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