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).
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).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.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.defineWebAuthnRegisterEventHandler and defineWebAuthnAuthenticateEventHandler on the server with a credential store you implement, and useWebAuthn() on the client; offer alongside passwords with recovery.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.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.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).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.@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.beforeAll and reuse the cookie; test 401, 403 and object-level 404 paths; a sessionHooks test proves version invalidation.Install, config and the server middleware that hydrates the request context:
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
},
})
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:
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 }
})
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:
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
})
})
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:
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
})
<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>
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.
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.
The current helper names (defineOAuth<Provider>EventHandler, WebAuthn handlers, sessionHooks), the secure session field, and Better Auth's Nuxt integration status.
sessionVersion and the fetch hook; change the version in the database and confirm the next request is rejected with 401.requireRole and assertOwnsOrIsAdmin covering every role and ownership combination."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Data layer and databases
Choosing Postgres, SQLite or a KV, Drizzle versus Prisma versus Kysely, migrations in CI, connection pooling in serverless and edge, transactions and isolation, N+1 and indexes, keyset pagination, optimistic concurrency, DTOs, and Nitro's storage layer for KV and blobs.
Server caching and performance
The server-side cache stack in Nuxt — defineCachedEventHandler and cachedFunction with their options, storage drivers for multi-instance deployments, route rules, CDN headers, invalidation without tags, request coalescing — and the render and query costs that caching cannot fix — sequential awaits, N+1, cold starts, payload size, streaming and observability.