Skip to content
← All questions
Advanced

How do you handle authentication in Nuxt 3?

NuxtArchitecture

The standard pattern uses four pieces working together: a useAuth composable that exposes the auth state and methods, a plugin that initializes the user session on app start, a route middleware that protects pages, and a server middleware that protects API routes. Tokens are stored in cookies (not localStorage) because cookies are accessible during SSR.

Why cookies, not localStorage

localStorage doesn't exist on the server. During SSR, the server needs to know who the user is to render personalized content and protect pages. Cookies are sent with every HTTP request, so the server can read them during both SSR and API calls.

ts
// composables/useAuth.ts
export function useAuth() {
  const user = useState<User | null>('auth-user', () => null)
  const token = useCookie('auth-token', {
    maxAge: 60 * 60 * 24 * 7,  // 7 days
    sameSite: 'lax',
    secure: true
  })

  const loggedIn = computed(() => !!user.value)

  async function login(email: string, password: string) {
    const response = await $fetch<{ user: User; token: string }>('/api/auth/login', {
      method: 'POST',
      body: { email, password }
    })
    token.value = response.token
    user.value = response.user
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    token.value = null
    user.value = null
    navigateTo('/login')
  }

  async function fetchUser() {
    if (!token.value) {
      user.value = null
      return
    }
    try {
      user.value = await $fetch<User>('/api/auth/me')
    } catch {
      token.value = null
      user.value = null
    }
  }

  return { user, token, loggedIn, login, logout, fetchUser }
}

useCookie is SSR-safe: it reads from the request headers on the server and from document.cookie on the client. useState ensures the user state doesn't leak between requests on the server.

Plugin: initialize session on app start

ts
// plugins/auth.ts
export default defineNuxtPlugin(async () => {
  const { fetchUser } = useAuth()
  await fetchUser()
})

This runs once when the app starts (on both server and client). If the user has a valid token cookie, fetchUser loads their profile. If the token is expired or invalid, it clears the auth state.

Route middleware: protect pages

ts
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const { loggedIn } = useAuth()

  if (!loggedIn.value) {
    return navigateTo('/login')
  }
})
ts
// middleware/guest.ts (redirect logged-in users away from login page)
export default defineNuxtRouteMiddleware(() => {
  const { loggedIn } = useAuth()

  if (loggedIn.value) {
    return navigateTo('/dashboard')
  }
})

Apply them to pages with definePageMeta:

vue
<!-- pages/dashboard.vue -->
<script setup>
definePageMeta({ middleware: 'auth' })
</script>
vue
<!-- pages/login.vue -->
<script setup>
definePageMeta({ middleware: 'guest' })
</script>

Server middleware: protect API routes

Route middleware only protects pages. Anyone can call /api/admin/users directly. Protect the data at the server layer:

ts
// server/middleware/auth.ts
export default defineEventHandler((event) => {
  const url = getRequestURL(event).pathname

  if (!url.startsWith('/api/') || url.startsWith('/api/auth/')) {
    return  // skip non-API routes and public auth endpoints
  }

  const token = getCookie(event, 'auth-token')
  if (!token) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  try {
    event.context.user = verifyJWT(token)
  } catch {
    throw createError({ statusCode: 401, statusMessage: 'Invalid token' })
  }
})

Now every API route can access the authenticated user through event.context.user:

ts
// server/api/auth/me.get.ts
export default defineEventHandler((event) => {
  const user = event.context.user
  if (!user) throw createError({ statusCode: 401 })
  return user
})

Server API routes: login and logout

ts
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  const user = await findUserByEmail(email)
  if (!user || !await verifyPassword(password, user.passwordHash)) {
    throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
  }

  const token = signJWT({ userId: user.id, role: user.role })

  setCookie(event, 'auth-token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7
  })

  return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
})
ts
// server/api/auth/logout.post.ts
export default defineEventHandler((event) => {
  deleteCookie(event, 'auth-token')
  return { ok: true }
})

Setting httpOnly: true on the server-side cookie prevents JavaScript from accessing the token, which protects against XSS attacks. The client-side useCookie('auth-token') can detect whether the cookie exists (for the loggedIn check) but cannot read an httpOnly cookie's value. For the token value itself, the server handles everything.

How the pieces connect

App starts
  → auth plugin runs → fetchUser() → reads cookie → loads user

User visits /dashboard
  → auth middleware → loggedIn? → yes → render page
                                → no  → redirect to /login

User calls /api/admin/users
  → server middleware → valid token? → yes → attach user to context → handle request
                                     → no  → 401 Unauthorized

User clicks logout
  → useAuth().logout() → POST /api/auth/logout → clear cookie → clear state → redirect

Summary

PieceLocationResponsibility
useAuth composablecomposables/Auth state, login/logout methods
Auth pluginplugins/Initialize session on app start
Route middlewaremiddleware/Protect pages, redirect unauthenticated users
Server middlewareserver/middleware/Protect API routes, validate tokens
Server API routesserver/api/auth/Login, logout, token management
CookieSent with every requestToken storage (SSR-safe, httpOnly)

Released under the MIT License.