Authentication in Nuxt: Patterns That Actually Scale
A practical guide to Nuxt authentication — from session cookies vs JWTs to better-auth integration, middleware protection, and patterns that hold up in production.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Authentication is one of those topics where bad advice is everywhere and the consequences of getting it wrong are severe. I have seen production Nuxt applications storing JWTs in localStorage (vulnerable to XSS), using client-side route guards as the only protection (trivially bypassable), and implementing custom session management that had subtle security holes.
This guide is about building authentication correctly. Not the fastest approach, not the simplest demo — the patterns that are actually secure and maintainable in production.
The Core Decision: Sessions vs JWTs
Before writing any code, you need to make this architectural decision clearly, because it affects everything downstream.
HTTP-only cookie sessions store the session identifier in a cookie that JavaScript cannot read. The session data lives on the server (in a database or Redis). This is the approach web applications used for decades and it remains the most secure option for most applications.
JWTs (JSON Web Tokens) are self-contained tokens that encode session data. They are typically stored in memory or localStorage. The appeal is statelessness — the server does not need to look up session data on every request.
My recommendation: use sessions with HTTP-only cookies for most Nuxt applications. Here is why:
- HTTP-only cookies cannot be stolen by XSS attacks. A
localStorageJWT can be. - Session invalidation is immediate. To invalidate a JWT you need a blocklist, which eliminates the statelessness benefit.
- Session data can grow without affecting the token size. JWTs are sent on every request — large JWTs have real performance cost.
The JWT case is legitimate when: you have multiple backend services that need to verify identity without database calls, you are building a public API where the clients are not browsers, or you are using a third-party auth provider that issues JWTs.
Using better-auth
I have standardized on better-auth for Nuxt applications. It handles the session management correctly, supports multiple OAuth providers, and integrates cleanly with Prisma. The name is apt — it is a meaningfully better solution than rolling your own.
npm install better-auth
Configure it with your database adapter:
// server/lib/auth.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { prisma } from './prisma'
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
})
Create the catch-all API route:
// server/api/auth/[...all].ts
import { auth } from '~/server/lib/auth'
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event))
})
Protecting Routes With Middleware
Nuxt middleware runs before route navigation. Use it to protect authenticated routes:
// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { data: session } = await useAuth()
if (!session.value && to.path !== '/login') {
return navigateTo(`/login?redirect=${to.path}`)
}
})
Apply it to protected pages:
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
</script>
Or apply it globally in nuxt.config.ts:
router: {
middleware: ['auth'],
}
With the global approach, opt specific public pages out:
<!-- pages/index.vue -->
<script setup lang="ts">
definePageMeta({
middleware: [], // Override: no auth required
})
</script>
Server-Side Route Protection
This is the part most tutorials skip. Client-side middleware is a user experience enhancement, not a security boundary. A determined user can disable JavaScript and bypass client-side middleware entirely.
Every API route and server-rendered page that contains private data must verify authentication on the server:
// server/api/user/profile.get.ts
import { auth } from '~/server/lib/auth'
export default defineEventHandler(async (event) => {
const session = await auth.api.getSession({
headers: event.headers,
})
if (!session) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
const profile = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
})
return profile
})
Create a utility to avoid repeating this check:
// server/utils/requireAuth.ts
import { auth } from '~/server/lib/auth'
export async function requireAuth(event: H3Event) {
const session = await auth.api.getSession({
headers: event.headers,
})
if (!session) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
return session
}
// server/api/user/profile.get.ts
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
// session.user is available here
})
Role-Based Access Control
For applications with multiple user roles (admin, editor, viewer), add a role check utility:
// server/utils/requireRole.ts
type Role = 'admin' | 'editor' | 'viewer'
export async function requireRole(event: H3Event, role: Role) {
const session = await requireAuth(event)
if (session.user.role !== role && session.user.role !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
return session
}
Define roles in your Prisma schema and populate them on the session object through better-auth's session customization:
export const auth = betterAuth({
// ...
session: {
additionalFields: {
role: {
type: 'string',
required: false,
},
},
},
})
The useAuth Composable
Build a clean composable that your components use — do not scatter raw auth calls throughout your pages:
// composables/useAuth.ts
export function useAuth() {
const session = useState<Session | null>('session', () => null)
const loading = ref(false)
async function login(email: string, password: string) {
loading.value = true
try {
const result = await $fetch('/api/auth/sign-in/email', {
method: 'POST',
body: { email, password },
})
session.value = result.session
await navigateTo('/dashboard')
} catch (error) {
throw error
} finally {
loading.value = false
}
}
async function logout() {
await $fetch('/api/auth/sign-out', { method: 'POST' })
session.value = null
await navigateTo('/login')
}
const isAuthenticated = computed(() => session.value !== null)
const user = computed(() => session.value?.user ?? null)
return { session, loading, isAuthenticated, user, login, logout }
}
Handling Token Refresh
If you are using JWTs (for a legitimate use case), handle token refresh automatically:
// plugins/auth-refresh.ts
export default defineNuxtPlugin(() => {
$fetch.create({
onResponseError: async ({ response }) => {
if (response.status === 401) {
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
// Retry the original request
} catch {
await navigateTo('/login')
}
}
},
})
})
Security Checklist Before Launch
Before shipping auth to production, verify:
- Passwords are hashed with bcrypt or argon2 (better-auth handles this)
- Session cookies are HTTP-only and SameSite=Strict
- All private API routes check authentication server-side
- Password reset tokens expire after a reasonable window (1 hour)
- Login rate limiting is in place (better-auth has built-in rate limiting)
- Email verification is required before accessing protected features
- Your database does not store plain-text passwords anywhere in logs
Authentication is not a feature you build and forget. Review your implementation annually, keep your auth libraries updated, and take security reports seriously.
If you are designing the authentication architecture for a Nuxt application or need a security review of an existing implementation, I can help. Book a call at calendly.com/jamesrossjr.