Fullstack with Nitro

Auth and sessions in Nuxt

Implementing authentication end to end with nuxt-auth-utils — sealed sessions, password and OAuth login, passkeys, session hooks — plus authorisation as data with role and object checks, SSR cookie forwarding, multi-tenant context, logout and invalidation, and the alternatives (Better Auth, Auth.js, hosted providers).

The security section covers the threat model; this page is the implementation a Nuxt fullstack developer is expected to produce in an afternoon: sessions that work with SSR, login by password and by provider, a permission model, and the operational edges (logout everywhere, tenants, tests).

Know

  • nuxt-auth-utils (the documented recipe): npx nuxt module add auth-utils; a NUXT_SESSION_PASSWORD of 32+ characters seals the session into an encrypted, HttpOnly, Secure, SameSite=Lax cookie (h3's useSession under the hood). Server API: setUserSession(event, data), replaceUserSession, getUserSession (empty object when anonymous), requireUserSession (throws 401), clearUserSession. Client composable useUserSession(): loggedIn, user, session, ready, fetch() (refresh from /api/_auth/session), clear(), openInPopup() for OAuth. Session data has a public user part sent to the client and a secure part that only the server sees (provider refresh tokens, internal ids).
  • Password login: hashPassword/verifyPassword (scrypt) helpers; the login route validates the body, verifies, regenerates the session (clearUserSession then setUserSession), and returns; rate limiting and generic errors as in the security page.
  • OAuth: defineOAuth<Provider>EventHandler({ config, onSuccess, onError }) for dozens of providers (GitHub, Google, Microsoft, Auth0, Keycloak, Apple, …) implementing the authorization-code flow with PKCE where supported; provider credentials live in runtimeConfig.oauth.<provider> and arrive via NUXT_OAUTH_<PROVIDER>_CLIENT_ID/SECRET; onSuccess maps the provider profile to your user and creates your own session.
  • Passkeys: defineWebAuthnRegisterEventHandler and defineWebAuthnAuthenticateEventHandler on the server with a credential store you implement, and useWebAuthn() on the client; offer alongside passwords with recovery.
  • Session hooks: sessionHooks.hook('fetch', async (session, event) => { ... }) runs on every session read and can enrich or reject (e.g., load fresh roles, enforce a session version for "log out everywhere"); sessionHooks.hook('clear', ...) on logout.
  • Where checks live: a server middleware reads the session once into event.context.user; handlers call requireUserSession (or read the context) and then an authorisation helper (requireRole(event, 'admin'), assertOwns(order, user)); route middleware in the app redirects for UX with useUserSession().loggedIn; SSR forwards the cookie to API calls with useRequestFetch() so the server render is authenticated.
  • Authorisation as data: roles (admin, editor, customer) for coarse access and attribute rules for object-level access (ownership, tenant membership, state); centralised in server/utils/authorize.ts, deny by default, unit-tested as a table. Multi-tenant: the tenant id comes from the subdomain, path or session, is set in event.context.tenant, and every query filters by it (or the database enforces it with row-level security).
  • Session lifecycle: absolute expiry (maxAge) plus idle refresh, regeneration on login and privilege change, invalidation on password change (bump a sessionVersion on the user and check it in the fetch hook), logout clears the cookie, "log out everywhere" bumps the version.
  • Alternatives: Better Auth (framework-agnostic TypeScript auth with a Nuxt integration, plugins for 2FA, organisations, passkeys), @sidebase/nuxt-auth (Auth.js providers), hosted identity (Auth0, Clerk, Logto, Supabase Auth, Keycloak) when SSO, compliance or user management should not be your code; in every case still create an HttpOnly first-party session for SSR.
  • Testing: log in through the real route in beforeAll and reuse the cookie; test 401, 403 and object-level 404 paths; a sessionHooks test proves version invalidation.

How it works

Install, config and the server middleware that hydrates the request context:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  runtimeConfig: {
    session: { maxAge: 60 * 60 * 24 * 7, password: '' }, // NUXT_SESSION_PASSWORD (32+ chars)
    oauth: { github: { clientId: '', clientSecret: '' } }, // NUXT_OAUTH_GITHUB_CLIENT_ID / _CLIENT_SECRET
  },
})
server/middleware/session.ts
export default defineEventHandler(async (event) => {
  const { user } = await getUserSession(event)
  event.context.user = user ?? null // typed via H3EventContext augmentation
})

Login, OAuth and passkeys, each ending in the same first-party session:

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

export default defineEventHandler(async (event) => {
  const { email, password } = await readValidatedBody(event, z.object({ email: z.string().email(), password: z.string().min(8).max(200) }).parse)
  await assertRateLimit(event, `login:${email.toLowerCase()}`, { max: 5, windowSec: 300 })
  const user = await useDb().query.users.findFirst({ where: (u, { eq }) => eq(u.email, email.toLowerCase()) })
  const ok = user ? await verifyPassword(user.passwordHash, password) : false
  if (!ok) throw createError({ status: 401, statusText: 'Invalid credentials' })

  await clearUserSession(event) // regenerate: no session fixation
  await setUserSession(event, {
    user: { id: user!.id, email: user!.email, role: user!.role },
    secure: { sessionVersion: user!.sessionVersion },
    loggedInAt: Date.now(),
  })
  return { ok: true }
})
server/routes/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
  config: { scope: ['user:email'] },
  async onSuccess(event, { user: profile, tokens }) {
    const user = await upsertUserFromProvider(useDb(), { provider: 'github', providerId: String(profile.id), email: profile.email })
    await clearUserSession(event)
    await setUserSession(event, {
      user: { id: user.id, email: user.email, role: user.role },
      secure: { sessionVersion: user.sessionVersion, githubToken: tokens.access_token }, // server-only
    })
    return sendRedirect(event, '/')
  },
  onError: event => sendRedirect(event, '/login?error=github'),
})

Session hooks for "log out everywhere", and authorisation helpers as data:

server/plugins/session.ts
export default defineNitroPlugin(() => {
  sessionHooks.hook('fetch', async (session) => {
    if (!session.user) return
    const current = await useDb().query.users.findFirst({ columns: { sessionVersion: true, role: true }, where: (u, { eq }) => eq(u.id, session.user!.id) })
    if (!current || current.sessionVersion !== session.secure?.sessionVersion) throw createError({ status: 401, statusText: 'Session expired' })
    session.user.role = current.role // fresh roles on every request
  })
})
server/utils/authorize.ts
type Role = 'admin' | 'editor' | 'customer'
const RANK: Record<Role, number> = { customer: 0, editor: 1, admin: 2 }

export function requireRole(event: H3Event, role: Role) {
  const user = event.context.user
  if (!user) throw createError({ status: 401, statusText: 'Unauthorized' })
  if (RANK[user.role] < RANK[role]) throw createError({ status: 403, statusText: 'Forbidden' })
  return user
}

export function assertOwnsOrIsAdmin<T extends { userId: string }>(event: H3Event, record: T | null | undefined): T {
  const user = event.context.user
  if (!record || !user || (record.userId !== user.id && user.role !== 'admin')) throw createError({ status: 404, statusText: 'Not found' })
  return record
}

Client side: UX middleware, SSR-forwarded API calls and the session composable:

app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { loggedIn } = useUserSession()
  if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
app/components/Account/Menu.vue
<script setup lang="ts">
const { user, loggedIn, clear, fetch: refresh } = useUserSession()
const api = useRequestFetch() // carries the session cookie during SSR
const { data: profile } = await useAsyncData('profile', () => api('/api/account/profile'), { watch: [loggedIn] })
async function logout() {
  await $fetch('/api/auth/logout', { method: 'POST' })
  await clear()
  await navigateTo('/')
}
</script>
Gotcha· Roles cached in the cookie

A sealed session stores the role at login; demoting a user does nothing until they log in again. Either re-read roles in the fetch session hook on every request (cheap with a small query) or keep roles out of the session and load them from the database per request.

Gotcha· Provider tokens in the public session

Anything under user is sent to the client by /api/_auth/session. Refresh tokens and provider access tokens belong in the secure part of the session, which stays server-side, or in the database.

Verify before the interview:

The current helper names (defineOAuth<Provider>EventHandler, WebAuthn handlers, sessionHooks), the secure session field, and Better Auth's Nuxt integration status.

docs ↗

Exercise

Exercise
  • Implement password login and the GitHub OAuth route; log in both ways and inspect the session cookie's attributes and size.
  • Add sessionVersion and the fetch hook; change the version in the database and confirm the next request is rejected with 401.
  • Write the authorisation table test for requireRole and assertOwnsOrIsAdmin covering every role and ownership combination.

Be able to say

Be able to say· How would you implement authentication in a Nuxt app?

"With nuxt-auth-utils, which is the documented recipe: an encrypted, HttpOnly, Secure, SameSite=Lax session cookie sealed with a 32-character password. A login route validates the body, verifies the scrypt hash, regenerates the session and stores the public user data plus a server-only secure part; OAuth providers go through the module's handlers with PKCE and end in the same first-party session; passkeys through its WebAuthn helpers. A server middleware puts the user on event.context, handlers call requireUserSession and an authorisation helper that checks role and ownership, and a session fetch hook re-reads roles and a session version so demotions and 'log out everywhere' take effect immediately. The client uses useUserSession for loggedIn and user, route middleware redirects for UX only, and useRequestFetch forwards the cookie so server rendering is authenticated. If the product needs SSO, organisations or compliance features, I would reach for Better Auth or a hosted provider and still keep a first-party session for SSR."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.