Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 046 / 100

Authentication in Nuxt: a practical guide for developers

Sessions and protected pages.

Expona Admin7 min read

By day 46 of our Vue.js and Nuxt journey, you've built components, fetched data, and styled pages. Now comes the part that separates hobby projects from production systems: keeping the wrong people out. Authentication in Nuxt: a practical guide for developers means moving beyond simple client-side checks to a real, server-backed session system.

Why Authentication Matters in Production

We've all seen applications where the only barrier to accessing someone else's data is a forgotten v-if or a frontend redirect. The browser runs user code, so anything client-side is a suggestion, not a rule. Real authentication requires the server to verify who you are before handing over protected resources. Nuxt gives us excellent tools to do this right.

Server Sessions — Sealed Cookies with nuxt-auth-utils

The foundation of secure authentication in Nuxt is nuxt-auth-utils, a module that handles sealed session cookies. These aren't plain JSON objects sitting in localStorage—they're encrypted, tamper-proof containers that live on the server and are signed so the browser can't fake them.

Start by installing the module:

typescript
npm install nuxt-auth-utils

Add it to your nuxt.config.ts:

typescript
export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  runtimeConfig: {
    session: {
      maxAge: 60 * 60 * 24 * 7, // 7 days
    },
  },
})

Now create a server route to handle login. In server/api/auth/login.post.ts:

typescript
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  // Validate credentials against your database
  const user = await db.users.findOne({ email })
  
  if (!user || !await verifyPassword(password, user.passwordHash)) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid email or password',
    })
  }

  // Set the session cookie
  await setUserSession(event, {
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
    },
  })

  return { success: true }
})

The setUserSession function encrypts your user data into a sealed cookie. The browser stores it, but can't read or modify it. On each request, Nuxt automatically decrypts and validates it on the server.

To access the session in other server routes or middleware, use getUserSession:

typescript
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  
  if (!session?.user) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  // Now you know who made this request
  console.log(`User ${session.user.id} requested this endpoint`)
  return { /* your response */ }
})

OAuth — Built-in Handlers for Common Providers

Not everyone wants to manage passwords. OAuth delegates authentication to trusted providers like Google, GitHub, or Discord. Nuxt-auth-utils includes pre-built handlers that handle the full OAuth flow.

Set up your OAuth credentials with a provider (Google Console, GitHub App, etc.) and add them to your .env:

typescript
NUXT_OAUTH_GOOGLE_CLIENT_ID=your-client-id
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
NUXT_SESSION_SECRET=your-random-secret-key-min-32-chars

Nuxt-auth-utils automatically creates OAuth routes. On your login page, link to the OAuth endpoint:

typescript
<template>
  <div>
    <a href="/api/auth/google">Sign in with Google</a>
  </div>
</template>

The user is redirected to Google, approves your app, and gets sent back to a callback route. The module handles everything—token exchange, user profile retrieval, session creation. On return, they're authenticated.

If you need custom logic (like creating a new user record or assigning a default role), create a file at server/api/auth/google.get.ts to override the default handler:

typescript
export default defineOAuthGoogleEventHandler({
  async onSuccess(event, { user }) {
    // Check if user exists in your database
    let dbUser = await db.users.findOne({ email: user.email })
    
    if (!dbUser) {
      dbUser = await db.users.create({
        email: user.email,
        name: user.name,
        avatar: user.picture,
        role: 'user', // default role
      })
    }

    // Set session with your user record
    await setUserSession(event, {
      user: {
        id: dbUser.id,
        email: dbUser.email,
        name: dbUser.name,
        role: dbUser.role,
      },
    })

    // Redirect home or to a welcome page
    await sendRedirect(event, '/')
  },

  async onError(event, error) {
    console.error('OAuth error:', error)
    await sendRedirect(event, '/login?error=oauth_failed')
  },
})

Protect Pages — Route Middleware Checks the Session

With sessions established, the next step is preventing unauthorized page views. Nuxt middleware runs before rendering a page, making it the perfect place to verify authentication.

Create middleware/auth.ts:

typescript
export default defineRouteMiddleware(async (to, from) => {
  const { data: session } = await useFetch('/api/auth/session')

  if (!session.value?.user) {
    return navigateTo('/login')
  }

  // Optional: check specific roles or permissions
  if (to.meta.requiredRole && session.value.user.role !== to.meta.requiredRole) {
    return navigateTo('/forbidden')
  }
})

Apply it to pages you want to protect:

typescript
// pages/dashboard.vue
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
})
</script>

<template>
  <div>
    <h1>Dashboard</h1>
    <!-- Your protected content -->
  </div>
</template>

Users without an active session are redirected to /login before the page even renders. No flash of forbidden content, no race conditions.

Protect APIs — requireUserSession in Server Routes

Pages are just one part of the picture. Your server routes—the actual endpoints that fetch or mutate data—also need guards. The requireUserSession utility throws a 401 error if no session exists, cleaning up your code:

typescript
// server/api/posts/index.ts
export default defineEventHandler(async (event) => {
  // This throws a 401 if not authenticated
  const session = await requireUserSession(event)

  // At this point, session.user is guaranteed to exist
  const posts = await db.posts.find({ authorId: session.user.id })
  
  return posts
})

Compare that to the verbose alternative without the utility:

typescript
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  
  if (!session?.user) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  const posts = await db.posts.find({ authorId: session.user.id })
  return posts
})

The difference is small but adds up across dozens of routes. More importantly, requireUserSession makes your intent clear: this endpoint is not public.

For finer-grained control, check permissions inside the route:

typescript
// server/api/admin/settings.patch.ts
export default defineEventHandler(async (event) => {
  const session = await requireUserSession(event)

  if (session.user.role !== 'admin') {
    throw createError({ 
      statusCode: 403, 
      statusMessage: 'Only admins can update settings' 
    })
  }

  const settings = await readBody(event)
  await db.settings.update(settings)
  return { success: true }
})

Common Mistakes

  • Trusting the frontend. Never rely solely on route middleware or client-side checks. Always validate on the server, in every API route. Middleware is for UX; server validation is for security.
  • Weak session secrets. Use a random string at least 32 characters long for NUXT_SESSION_SECRET. A predictable secret means anyone can forge sessions.
  • Forgetting to handle logout. Create a logout route that clears the session using clearUserSession(event). Without it, cookies persist even after login out.
  • Exposing sensitive data in the session. Store only identifiers and public info in the session (id, email, role). Never store passwords or API keys. Fetch sensitive data fresh from the database when needed.
  • Ignoring CSRF for forms. If you're not using a framework that handles CSRF automatically, manually verify tokens on state-changing requests (POST, PATCH, DELETE).

Key Takeaways

  • Sealed session cookies via nuxt-auth-utils are encrypted on the server and tamper-proof, making them far safer than localStorage for authentication state.
  • OAuth handlers eliminate password management and provide a seamless login flow for users familiar with major providers.
  • Route middleware protects the page layer, but server route guards with requireUserSession are what actually keep data safe.
  • Always validate authentication on the server, in every endpoint that needs it. The frontend is a convenience layer, not a security boundary.
  • Session secrets, logout handlers, and CSRF protection are easy to overlook but critical for production systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles