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
- Complex OAuth2 Integration: Provider configuration, callback handling, token refresh, error recovery — every step is a potential pitfall
- Chaotic Session Management: Where to store JWT? How to rotate Refresh Tokens? Multi-tab session synchronization?
- Difficult Passwordless Implementation: Complex WebAuthn API, registration flow design, cross-device sync
- Coarse Permission Control: Hardcoded frontend permissions, unauthenticated APIs, delayed role changes
- 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.
Recommended Online Tools
- JSON Formatter (JWT debugging): /en/json/format
- Hash Calculator (password hashing): /en/encode/hash
- Base64 Encoder/Decoder (Token parsing): /en/encode/base64
Try these browser-local tools — no sign-up required →