---
title: "Authentication and sessions in Nuxt with nuxt-auth-utils · vuejs.school"
description: "Build an email and password login in Nuxt 4.3 with nuxt-auth-utils: scrypt hashes, a sealed session cookie, a protected API route and a logout."
canonical_url: "https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils"
meta:
  "og:description": "Build an email and password login in Nuxt 4.3 with nuxt-auth-utils: scrypt hashes, a sealed session cookie, a protected API route and a logout."
  "og:image": "https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYW5kIHNlc3Npb25zIGluIE51eHQgd2l0aCBudXh0LWF1dGgtdXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Authentication%2Band%2Bsessions%2Bin%2BNuxt%2Bwith%2Bnuxt-auth-utils%2Cdescription_~QnVpbGQgYW4gZW1haWwgYW5kIHBhc3N3b3JkIGxvZ2luIGluIE51eHQgNC4zIHdpdGggbnV4dC1hdXRoLXV0aWxzOiBzY3J5cHQgaGFzaGVzLCBhIHNlYWxlZCBzZXNzaW9uIGNvb2tpZSwgYSBwcm90ZWN0ZWQgQVBJIHJvdXRlIGFuZCBhIGxvZ291dC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1hbmQtc2Vzc2lvbnMtd2l0aC1udXh0LWF1dGgtdXRpbHMi%2Cs_QcWQ-mHVh3OEFvbg.png"
  "og:image:alt": "Authentication and sessions in Nuxt with nuxt-auth-utils · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Authentication and sessions in Nuxt with nuxt-auth-utils · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYW5kIHNlc3Npb25zIGluIE51eHQgd2l0aCBudXh0LWF1dGgtdXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Authentication%2Band%2Bsessions%2Bin%2BNuxt%2Bwith%2Bnuxt-auth-utils%2Cdescription_~QnVpbGQgYW4gZW1haWwgYW5kIHBhc3N3b3JkIGxvZ2luIGluIE51eHQgNC4zIHdpdGggbnV4dC1hdXRoLXV0aWxzOiBzY3J5cHQgaGFzaGVzLCBhIHNlYWxlZCBzZXNzaW9uIGNvb2tpZSwgYSBwcm90ZWN0ZWQgQVBJIHJvdXRlIGFuZCBhIGxvZ291dC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1hbmQtc2Vzc2lvbnMtd2l0aC1udXh0LWF1dGgtdXRpbHMi%2Cs_QcWQ-mHVh3OEFvbg.png"
  "twitter:image:alt": "Authentication and sessions in Nuxt with nuxt-auth-utils · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What a sealed cookie session is

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.ts**Breaks***

```
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.ts**Works***

```
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 stuck**3 questions from the Authentication, sessions and CSRF quiz.

[**Practice now**](https://vuejs.school/learn/security/authentication-and-session-failures?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**API security for AI features: rate limits, prompt injection and error handling**](https://vuejs.school/community/blog/api-security-for-ai-features) [Tutorial · 8 min read**Supply chain security: lockfiles, provenance and OWASP A03**](https://vuejs.school/community/blog/supply-chain-security-lockfiles-provenance-and-owasp-a03)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-24","description":"Build an email and password login in Nuxt 4.3 with nuxt-auth-utils: scrypt hashes, a sealed session cookie, a protected API route and a logout.","name":"Authentication and sessions in Nuxt with nuxt-auth-utils","url":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils","breadcrumb":{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-24","description":"Build an email and password login in Nuxt 4.3 with nuxt-auth-utils: scrypt hashes, a sealed session cookie, a protected API route and a logout.","headline":"Authentication and sessions in Nuxt with nuxt-auth-utils","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYW5kIHNlc3Npb25zIGluIE51eHQgd2l0aCBudXh0LWF1dGgtdXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Authentication%2Band%2Bsessions%2Bin%2BNuxt%2Bwith%2Bnuxt-auth-utils%2Cdescription_~QnVpbGQgYW4gZW1haWwgYW5kIHBhc3N3b3JkIGxvZ2luIGluIE51eHQgNC4zIHdpdGggbnV4dC1hdXRoLXV0aWxzOiBzY3J5cHQgaGFzaGVzLCBhIHNlYWxlZCBzZXNzaW9uIGNvb2tpZSwgYSBwcm90ZWN0ZWQgQVBJIHJvdXRlIGFuZCBhIGxvZ291dC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1hbmQtc2Vzc2lvbnMtd2l0aC1udXh0LWF1dGgtdXRpbHMi%2Cs_QcWQ-mHVh3OEFvbg.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/authentication-and-sessions-with-nuxt-auth-utils#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Authentication and sessions in Nuxt with nuxt-auth-utils","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYW5kIHNlc3Npb25zIGluIE51eHQgd2l0aCBudXh0LWF1dGgtdXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Authentication%2Band%2Bsessions%2Bin%2BNuxt%2Bwith%2Bnuxt-auth-utils%2Cdescription_~QnVpbGQgYW4gZW1haWwgYW5kIHBhc3N3b3JkIGxvZ2luIGluIE51eHQgNC4zIHdpdGggbnV4dC1hdXRoLXV0aWxzOiBzY3J5cHQgaGFzaGVzLCBhIHNlYWxlZCBzZXNzaW9uIGNvb2tpZSwgYSBwcm90ZWN0ZWQgQVBJIHJvdXRlIGFuZCBhIGxvZ291dC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1hbmQtc2Vzc2lvbnMtd2l0aC1udXh0LWF1dGgtdXRpbHMi%2Cs_QcWQ-mHVh3OEFvbg.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QXV0aGVudGljYXRpb24gYW5kIHNlc3Npb25zIGluIE51eHQgd2l0aCBudXh0LWF1dGgtdXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Authentication%2Band%2Bsessions%2Bin%2BNuxt%2Bwith%2Bnuxt-auth-utils%2Cdescription_~QnVpbGQgYW4gZW1haWwgYW5kIHBhc3N3b3JkIGxvZ2luIGluIE51eHQgNC4zIHdpdGggbnV4dC1hdXRoLXV0aWxzOiBzY3J5cHQgaGFzaGVzLCBhIHNlYWxlZCBzZXNzaW9uIGNvb2tpZSwgYSBwcm90ZWN0ZWQgQVBJIHJvdXRlIGFuZCBhIGxvZ291dC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hdXRoZW50aWNhdGlvbi1hbmQtc2Vzc2lvbnMtd2l0aC1udXh0LWF1dGgtdXRpbHMi%2Cs_QcWQ-mHVh3OEFvbg.png"}]}
```