Middleware, sessions and auth
Sign visitors in with a sealed cookie session, check it in every protected API route, redirect pages with route middleware, and avoid the redirect loop and the missing cookie on first load.
What you'll learn
When you finish, you can do these.- 1Tell server middleware from route middleware
- 2Sign a visitor in with a sealed cookie session
- 3Protect private API data from signed-out visitors
- 4Send signed-out visitors to sign in and back safely
The Corner Shop, the small online shop this track builds, has an account page that lists a visitor's orders. The page loads them from /api/orders, an API route: a server address that answers with data, not a page. Two things must hold. A visitor who is not signed in should land on a sign-in form, not an empty page. And the orders must reach no one else, not even someone who calls that address from a terminal.
The Code tab holds the shop's files: a sign-in route that remembers the visitor in a session, a check in each API route with private data, and a guard on the account page. You need one idea from the lesson "State: useState, Pinia and cookies". A cookie is a small value the browser sends with every request, and the visitor can change it. The session here is a cookie they cannot change.
Two kinds of middleware
Middleware is code that runs before the page or API route a request was aimed at. Nuxt has two kinds, in two folders, and only one sees API calls.
browser requestserver middlewareevery requestroute middlewarepage navigationshandler or pageresponse
Server middleware lives in server/middleware/ and runs for every request, before any page or API route. server/middleware/log.ts in the Code tab is one: it logs each request and lets it carry on.
Route middleware lives in app/middleware/ and runs in the Vue app before a page is entered. On the first load that happens on the server; after that, a click on a <NuxtLink>, Nuxt's link component, runs it in the browser, with no request to the server.
A route middleware file such as app/middleware/auth.ts runs on the pages that list it with definePageMeta({ middleware: 'auth' }). A file whose name ends in .global.ts runs before every page.
Either way, the file wraps a function in defineNuxtRouteMiddleware() that receives to, the route being entered. It returns nothing to let the visitor in, or navigateTo() to send them elsewhere.
Sign in with a sealed session
A session records who a visitor is between requests. Nuxt 4.6 seals it into a cookie: the data is encrypted and signed with a secret only the server knows, so the browser stores it but cannot read or change it. The sign-in route, server/api/login.post.ts in the Code tab, saves the visitor's name with useSession():
import { defineEventHandler, readValidatedBody, useSession } from 'nuxt/server'
import { z } from 'zod'
const Login = z.object({ name: z.string().trim().min(1).max(40) })
// DEMO ONLY: signs in any name
export default defineEventHandler(async (event) => {
const { name } = await readValidatedBody(event, Login)
const session = await useSession<{ user?: SessionUser }>(event)
await session.update({ user: { name } })
return { user: { name } }
})
readValidatedBody reads the posted name and checks it against Login, a rule written with the Zod library: 1 to 40 characters. session.update() stores the name, and the response sets the sealed cookie, nuxt-session, marked HttpOnly so page scripts cannot read it.
SessionUser, a type in shared/types/auth.ts, holds just the name: a session over 4096 bytes fails. The sign-out route, logout.post.ts, calls session.clear().
The import line matters. Every file that touches the session imports useSession from nuxt/server, Nuxt's own server helpers. Without it, files in server/ get h3's auto-imported useSession (h3 is the library under Nuxt's server), which cannot read these sessions. The demo signs in any name; a real app checks a password first (Authentication, sessions and CSRF, in the Security track).
The secret is NUXT_APP_SECRET. In development Nuxt generates one in .nuxt/app-secret; builds never do. A server started without it, or with one shorter than 32 characters, answers every session call with a 500: "appSecret is not set. Set NUXT_APP_SECRET to at least 32 characters, or pass a secret explicitly." Create one with openssl rand -base64 32, set it on every host, and keep it stable, because changing it signs everyone out.
Protect the data, then the page
The orders need their check in the API first. Route middleware never runs for /api/orders, so a check on the page protects nothing from curl, a terminal command that fetches any address. Put one helper in server/utils/, which Nuxt auto-imports into every server file:
import { createError, useSession } from 'nuxt/server'
import type { RequestEvent } from 'nuxt/server'
export async function requireUser(event: RequestEvent) {
const session = await useSession<{ user?: SessionUser }>(event)
if (!session.data.user) {
throw createError({ status: 401, statusText: 'Sign in first' })
}
return session.data.user
}
With no user in the session it throws a 401, the status for "not signed in". server/api/orders.get.ts calls it on its first line, so only a signed-in visitor gets the orders.
Route middleware then spares the visitor a blank account page: app/pages/account.vue asks for it with definePageMeta({ middleware: 'auth' }), so app/middleware/auth.ts runs first.
The middleware first reads useUser(), which keeps the signed-in user with useState: one value per visitor, shared with account.vue. When that is empty, it asks /api/me, an API route that returns the session's user, with useRequestFetch(): a $fetch, Nuxt's API caller, that carries the visitor's cookie:
export default defineNuxtRouteMiddleware(async (to) => {
const user = useUser()
if (!user.value) {
const { user: me } = await useRequestFetch()('/api/me')
user.value = me
}
if (!user.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})
A signed-out visitor who opens /account lands on /login?redirect=/account.
After signing in, app/pages/login.vue sends the visitor back to the page in ?redirect=. That value comes from the URL, so anyone can write a link such as /login?redirect=https://evil.example. navigateTo refuses another site's address with an error, and adding external: true to silence it would forward your visitors to evil.example, perhaps a fake copy of your shop. Accept only paths on your own site:
export function safeRedirect(value: unknown) {
if (typeof value !== 'string') return '/'
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return '/'
return value
}
login.vue calls navigateTo(safeRedirect(route.query.redirect)) once sign-in succeeds. Browsers read two leading slashes, or /\, as the start of another host, so //evil.example falls back to / too.
What goes wrong
Two mistakes in route middleware keep visitors from pages they should reach.
Picture the guard as a global middleware, app/middleware/auth.global.ts, which runs before every page, the sign-in page included:
export default defineNuxtRouteMiddleware(() => {
if (!useUser().value) {
return navigateTo('/login')
}
})
A signed-out visitor asks for /account and is sent to /login, where the same middleware sends them to /login again. In development the terminal shows [VUE_ROUTER_R0009] Detected a possibly infinite redirection in a navigation guard when going from "/account" to "/login" and the page is a 500. A production build has no such check: the request never answers, and the server's memory climbs until it crashes. Let the sign-in page through:
export default defineNuxtRouteMiddleware((to) => {
if (to.path === '/login') return
if (!useUser().value) {
return navigateTo('/login')
}
})
/login now shows its form.
The second mistake is a plain $fetch in auth.ts, in place of useRequestFetch():
const { user: me } = await $fetch('/api/me')
A signed-in visitor who clicks a link to /account gets in. The same visitor who reloads /account lands on /login. A reload runs the middleware on the server, and $fetch calls /api/me from inside the server without the visitor's headers, so the session cookie never arrives. useRequestFetch() forwards the incoming request's headers, cookie included, and behaves like $fetch in the browser:
const { user: me } = await useRequestFetch()('/api/me')
A reload of /account now shows the account page.
What to remember
- Server middleware runs for every request. Route middleware runs only before a page is entered, so it never sees an API call.
useSessionfromnuxt/serverseals the session into a cookie withNUXT_APP_SECRET, which builds never make: set it on every host, 32 characters or more.- Every protected API route checks the session itself, through one helper such as
requireUser()that throws a 401. - Treat
?redirect=as untrusted: accept only paths that start with a single/. - A global guard must let through the page it sends visitors to, or it loops.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
curl -i http://localhost:3000/api/orders: a 401 marked Sign in first. Sign in withcurl -i -c jar.txt -X POST -H 'content-type: application/json' -d '{"name":"Ana"}' http://localhost:3000/api/login: the response sets anuxt-sessioncookie markedHttpOnly; Secure; SameSite=Lax. Repeat the first call with-b jar.txt: Ana's order comes back. - Sign in through the form and open
/account. In DevTools, delete thenuxt-sessioncookie and reload: you land on/login?redirect=/account, and signing in brings you back. Then open/login?redirect=//evil.exampleand sign in: you land on/, not on another site. - Run
npm run build, thennode .output/server/index.mjswith no secret set, andcurl http://localhost:3000/api/me: a 500 that namesNUXT_APP_SECRET. Stop it and start again withNUXT_APP_SECRET="$(openssl rand -base64 32)" node .output/server/index.mjs: the same call returns{"user":null}.