Vue 3 and Nuxt · Building Apps with Nuxt
Authentication with Sessions and Route Middleware
Add login to a Nuxt app with sealed server sessions, protect API routes with a server helper, and guard pages with route middleware that works during SSR.
What you'll build
Login and logout API routes that store the user ID in an encrypted, httpOnly session cookie (h3's useSession), a requireUser server helper that protects API routes, and a route middleware that redirects anonymous visitors from /account to the login page, during server rendering as well as client navigation.
The idea
- h3's
useSession(event, { password })stores session data in a sealed (encrypted and signed) cookie; the password must be at least 32 characters and come from runtime config. - API routes check the session on the server:
requireUser(event)throws 401 when there's no user. This is the real security boundary. - Route middleware (
app/middleware/auth.ts, applied withdefinePageMeta({ middleware: "auth" })) improves the experience by redirecting before rendering; on the server it must forward the browser's cookie, whichuseRequestFetch()does. - The
nuxt-auth-utilsmodule packages the same pattern (sealed sessions, OAuth providers,requireUserSession).
The code
// file: nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: "2026-10-01",
devtools: { enabled: false },
runtimeConfig: { sessionPassword: "example-password-at-least-32-characters!" }, // NUXT_SESSION_PASSWORD
});
// file: app/app.vue
<template><main><NuxtPage /></main></template>
// file: server/utils/auth.ts
import type { H3Event } from "h3";
type SessionData = { userId?: string };
const users = new Map([["u1", { id: "u1", name: "Ana", email: "[email protected]", password: "correct-horse-battery" }]]);
export function getAppSession(event: H3Event) {
return useSession<SessionData>(event, {
password: useRuntimeConfig(event).sessionPassword,
name: "shop-session",
maxAge: 60 * 60 * 24 * 7,
cookie: { httpOnly: true, secure: true, sameSite: "lax" },
});
}
export function checkLogin(email: string, password: string) {
// Real apps compare password hashes (scrypt/argon2), never plain text.
return [...users.values()].find((u) => u.email === email && u.password === password) ?? null;
}
export async function requireUser(event: H3Event) {
const session = await getAppSession(event);
const user = session.data.userId ? users.get(session.data.userId) : undefined;
if (!user) throw createError({ statusCode: 401, statusMessage: "Log in required" });
return { id: user.id, name: user.name };
}
// file: server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody<{ email: string; password: string }>(event);
const user = checkLogin(email, password);
if (!user) throw createError({ statusCode: 401, statusMessage: "Invalid email or password" });
const session = await getAppSession(event);
await session.update({ userId: user.id });
return { name: user.name };
});
// file: server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
await (await getAppSession(event)).clear();
setResponseStatus(event, 204);
return null;
});
// file: server/api/me.get.ts
export default defineEventHandler((event) => requireUser(event));
// file: server/api/orders.get.ts
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
return [{ id: "o1", item: "Teapot", owner: user.id }];
});
// file: app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
const user = useState<{ name: string } | null>("user", () => null);
if (!user.value) {
user.value = await useRequestFetch()("/api/me").catch(() => null); // forwards the cookie during SSR
}
if (!user.value) return navigateTo(`/login?next=${encodeURIComponent(to.fullPath)}`);
});
// file: app/pages/account.vue
<script setup lang="ts">
definePageMeta({ middleware: "auth" });
const user = useState<{ name: string } | null>("user");
const { data: orders } = await useFetch("/api/orders");
</script>
<template>
<h1>Signed in as {{ user?.name }}</h1>
<ul><li v-for="o in orders" :key="o.id">{{ o.id }}: {{ o.item }}</li></ul>
</template>
// file: app/pages/login.vue
<template><h1>Log in</h1></template>
// request: GET /account
// request: GET /api/orders
// request: POST /api/auth/login {"email": "[email protected]", "password": "wrong"}
// request: POST /api/auth/login {"email": "[email protected]", "password": "correct-horse-battery"}
// request: GET /account
// request: POST /api/auth/logout
// request: GET /api/ordersOutput
GET /account -> 302 (Location: /login?next=%2Faccount)
GET /api/orders -> 401
{"error": true, "url": "http://localhost:3000/api/orders", "statusCode": 401, "statusMessage": "Log in required", "message": "Log in required"}
POST /api/auth/login -> 401
{"error": true, "url": "http://localhost:3000/api/auth/login", "statusCode": 401, "statusMessage": "Invalid email or password", "message": "Invalid email or password"}
POST /api/auth/login -> 200
{"name": "Ana"}
GET /account -> 200
Signed in as Ana
o1: Teapot
POST /api/auth/logout -> 204
GET /api/orders -> 401
{"error": true, "url": "http://localhost:3000/api/orders", "statusCode": 401, "statusMessage": "Log in required", "message": "Log in required"}Run this on your computer — see “Get Started” for setup.
Common mistakes
- Protecting pages with middleware but leaving the API routes open; the API is the security boundary.
- Storing user IDs in plain, unsigned cookies or in
localStorage. - Calling
$fetch("/api/me")in middleware during SSR: it doesn't carry the visitor's cookie. UseuseRequestFetch()oruseFetch. - Hard-coding the session password; set it with an environment variable (
NUXT_SESSION_PASSWORD).
Key points
- Sealed, httpOnly session cookies via useSession (or nuxt-auth-utils).
- requireUser in every protected API route.
- Route middleware for redirects, forwarding cookies during SSR.
Further reading: Nuxt sessions and authentication recipe · Route middleware · h3 sessions
Practice questions
Where is the real security boundary in a Nuxt app with route middleware?
Answer
The server API routes: they must check the session themselves. Route middleware only redirects for a better experience and can be bypassed by calling the API directly.
Why use useRequestFetch in middleware during SSR?
Answer
During server rendering, plain $fetch to your own API doesn't include the visitor's cookies; useRequestFetch forwards the incoming request's headers.
What does h3's useSession store?
Answer
Session data in a sealed cookie: encrypted and signed with a server-side password, so clients can't read or modify it.
Open in the app to mark lessons complete, take notes and track your progress.