Skip to content
TutorialSecurity & OWASP

Authentication and sessions in Nuxt with nuxt-auth-utils

Most authentication bugs live in code you write once and never read again. nuxt-auth-utils keeps that code short: setUserSession on login, requireUserSession on every protected route, clear() to log out.

vuejs.school team24 Feb 20266 min readnuxt-auth-utils 0.5 · Nuxt 4.3

Authentication failures are A07 in the OWASP Top 10:2025: passwords stored badly, login forms that tell an attacker which emails exist, sessions that never end. nuxt-auth-utils, a small Nuxt module, takes care of the cryptography: it hashes passwords and seals the session cookie. The code left for you stays short enough to check line by line.

You will build registration with hashed passwords, a login, an API route that refuses anonymous requests, a page only signed-in users can open, and a logout. The examples use Nuxt 4.3 with the Nuxt 4 layout (pages and middleware inside app/, server/ at the project root) and nuxt-auth-utils 0.5.29, published on 17 February. Every API below comes from the module's README and source at that version.

A session is how the server remembers who you are between requests. Many frameworks keep session data in a database and give the browser only a random id. nuxt-auth-utils keeps the data in the cookie itself and seals it: the content is encrypted and signed with a secret password, using the session helpers of h3, the HTTP layer under Nuxt's server. The browser stores a string it cannot read. If anyone changes a single character, the server fails to unseal it and treats the request as anonymous.

The cookie is called nuxt-session. h3 marks it HttpOnly, so JavaScript on the page cannot read it, and Secure, so it only travels over HTTPS. The module adds SameSite=Lax, which keeps it off most requests started by other sites. Because nothing is stored on the server, sessions survive restarts and need no database.

Two limits come with that design. A cookie holds at most 4096 bytes, so the session must stay small. And the server cannot cancel one cookie it has issued. Logging out deletes the cookie in that browser, but a copy taken earlier stays valid until it expires. That is why the next section gives sessions an expiry.

Install the module and configure the session

Add the module with the Nuxt CLI, and Zod 4 for validating request bodies (the examples use z.email(), which Zod 3 does not have):

npx nuxi@latest module add auth-utils
npm install zod

The module seals cookies with a secret it reads from NUXT_SESSION_PASSWORD, which must be at least 32 characters:

  • In development, the module generates one and appends it to .env the first time you run nuxt dev without it.
  • In production, you set it yourself in your host's environment settings. Never commit it.

Changing the secret later signs every user out at once, because no existing cookie can be unsealed with the new value. That makes it your emergency switch if session cookies are ever stolen.

The module passes runtimeConfig.session to h3, so that is where the expiry goes. maxAge is in seconds:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  runtimeConfig: {
    session: {
      maxAge: 60 * 60 * 24 * 7, // one week
    },
  },
})

Without maxAge, the sealed data never expires, and the cookie lasts until the browser decides to drop it. With it, the week runs from when the session started, usually the login. Later requests do not extend it, so an active user still signs in again after seven days.

Next, describe what a user looks like, so user.id is typed on the server and in components. The module's types live under the #auth-utils alias:

shared/types/auth.d.ts
declare module '#auth-utils' {
  interface User {
    id: string
    name: string
  }
}

export {}

Register and log in on the server

A password must never be stored as typed, or in any form that can be reversed. hashPassword() runs scrypt, a hashing function built to be slow and memory-hungry, so guessing passwords from a stolen table is expensive. Each hash carries its own random salt, so two users with the same password get different hashes.

The users live in Nitro's built-in key-value storage, which writes to .data/kv on a Node server. It stands in for your database: replace the two storage calls with your own queries.

server/utils/users.ts
interface StoredUser {
  id: string
  name: string
  passwordHash: string
}

const users = () => useStorage<StoredUser>('data:users')

export function findUser(email: string) {
  return users().getItem(email.toLowerCase())
}

export async function createUser(email: string, name: string, password: string) {
  const user: StoredUser = { id: crypto.randomUUID(), name, passwordHash: await hashPassword(password) }
  await users().setItem(email.toLowerCase(), user)
  return user
}

Functions exported from server/utils are auto-imported in every server route, like the module's own helpers. Registration validates the body, creates the user and signs them in:

server/api/register.post.ts
import { z } from 'zod'

const bodySchema = z.object({
  email: z.email(),
  name: z.string().min(1).max(80),
  password: z.string().min(12).max(128),
})

export default defineEventHandler(async (event) => {
  const { email, name, password } = await readValidatedBody(event, body => bodySchema.parse(body))
  if (await findUser(email)) {
    throw createError({ statusCode: 409, statusMessage: 'Could not create the account' })
  }
  const user = await createUser(email, name, password)
  await setUserSession(event, { user: { id: user.id, name: user.name } })
  return { ok: true }
})

readValidatedBody() answers a malformed body with a 400 before your code runs. The 409 does reveal that an email is taken; on a public sign-up form, send a confirmation email instead and give the same reply in both cases.

Login looks the user up and checks the password. verifyPassword() takes the stored hash first and the typed password second:

server/api/login.post.ts
import { z } from 'zod'

const bodySchema = z.object({
  email: z.email(),
  password: z.string().min(1),
})

export default defineEventHandler(async (event) => {
  const { email, password } = await readValidatedBody(event, body => bodySchema.parse(body))
  const stored = await findUser(email)

  if (!stored || !(await verifyPassword(stored.passwordHash, password))) {
    throw createError({ statusCode: 401, statusMessage: 'Invalid email or password' })
  }

  await setUserSession(event, {
    user: { id: stored.id, name: stored.name },
    loggedInAt: Date.now(),
  })
  return { ok: true }
})

setUserSession() merges the new data into the session, extra fields such as loggedInAt included, and sends a fresh sealed cookie with the response.

An unknown email and a wrong password get the same message on purpose. A login that says "no such user" lets anyone test a list of addresses.

The module does not limit login attempts. OWASP lists unthrottled guessing under A07, so put a rate limit in front of this route before you go live.

Protect an API route

requireUserSession() reads the cookie, and if the session has no user, it throws a 401 with the message Unauthorized. Otherwise it returns the session with user typed as present:

server/api/notes.get.ts
export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  const notes = await useStorage<string[]>('data:notes').getItem(user.id)
  return notes ?? []
})

Call it at the top of every handler that returns private data, and take the user id from the session, never from the request body or query. That one habit closes the classic hole where a logged-in user reads someone else's notes by changing an id in the URL.

Guard pages and log in from the browser

On the client, useUserSession() gives you loggedIn, user, session, fetch() and clear(), among others. During server rendering, the module's plugin loads the session before your pages run, so loggedIn is already correct in the first HTML. A route middleware turns that into a guard:

app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const { loggedIn } = useUserSession()
  if (!loggedIn.value) return navigateTo('/login')
})

The login page posts the form, then calls the composable's fetch() before navigating. It is renamed refreshSession here so it does not hide the browser's own fetch:

app/pages/login.vue
<script setup lang="ts">
const { fetch: refreshSession } = useUserSession()
const form = reactive({ email: '', password: '' })
const error = ref('')

async function login() {
  error.value = ''
  try {
    await $fetch('/api/login', { method: 'POST', body: form })
    await refreshSession()
    await navigateTo('/account')
  }
  catch {
    error.value = 'Invalid email or password.'
  }
}
</script>

<template>
  <form @submit.prevent="login">
    <label>Email <input v-model="form.email" type="email" autocomplete="username" required></label>
    <label>Password <input v-model="form.password" type="password" autocomplete="current-password" required></label>
    <p v-if="error" role="alert">{{ error }}</p>
    <button type="submit">Log in</button>
  </form>
</template>

The browser stores the new cookie as soon as /api/login answers, but the client-side state does not know yet. refreshSession() asks /api/_auth/session for the current session. Leave it out and the middleware still sees loggedIn as false, sending the user straight back to the login page.

Show a private page and log out

The account page names the auth middleware in definePageMeta and loads its notes with useFetch, which forwards the browser's cookie when it runs on the server:

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

const { user, clear } = useUserSession()
const { data: notes } = await useFetch('/api/notes')

async function logout() {
  await clear()
  await navigateTo('/login')
}
</script>

<template>
  <h1>Hello, {{ user?.name }}</h1>
  <ul>
    <li v-for="(note, index) in notes" :key="index">{{ note }}</li>
  </ul>
  <button type="button" @click="logout">Log out</button>
</template>

clear() sends a DELETE to /api/_auth/session, where the module calls clearUserSession(event) and overwrites the cookie with an empty one. Call clearUserSession(event) yourself in server routes that should end the session too, such as deleting an account.

What not to put in the session

Anything outside the secure key is returned as plain JSON by /api/_auth/session, so the user can read it in the network tab. The common slip is to pass the whole database record:

server/api/login.post.tsBreaks
await setUserSession(event, { user: stored })

That puts the password hash into the cookie and then into the browser. TypeScript does not catch it, because extra properties on a variable pass the type check. Name the fields instead:

server/api/login.post.tsWorks
await setUserSession(event, { user: { id: stored.id, name: stored.name } })

The rules that follow from the design:

  • Store what identifies the user and what the interface shows: an id, a display name.
  • Keep permissions and roles in the database and check them on the server. A role copied into the cookie stays there until the next login, even after you revoke it.
  • Put server-only values, such as a third-party API token, under secure. The session route strips that key, but it still travels inside the sealed cookie and counts towards the 4096 bytes.
  • Never store a password, a hash or payment details, sealed or not.

Sign in with GitHub or Google

The same session works for OAuth, where another service confirms who the user is. The module ships event handlers for more than 40 providers, named on one pattern: defineOAuthGitHubEventHandler, defineOAuthGoogleEventHandler and so on. You put one in a route such as server/routes/auth/github.get.ts, set NUXT_OAUTH_GITHUB_CLIENT_ID and NUXT_OAUTH_GITHUB_CLIENT_SECRET, and call setUserSession() in its onSuccess callback, exactly as the password login does. Everything after that line, the guard, the protected routes and the logout, stays the same.

Check it stuck3 questions from the Authentication, sessions and CSRF quiz.
Practice now
Was this helpful?

Keep reading