Practice: authentication and deployment — the login flow and where the build output goes
The guide closes by gathering the pieces so far into one feature: authentication. Because the damage from getting it wrong is large, we decide "where things live" before we decide what works.
After that we go as far as putting the build output onto hosting.
What you'll learn in this chapter
- Why the token does not go in
localStorage, and what to use instead - Issuing an
httpOnlycookie on the server, and how it looks fromuseCookie - Blocking navigation with
defineNuxtRouteMiddlewareandnavigateTo. What happens when you forget thereturn - What
nuxt buildandnuxt generateproduce. The roles of200.htmland404.html - That the fallback configuration for static hosting differs by provider
Where to put the token
The comparison of storage locations is covered in Web engineering fundamentals — Browser storage.
Plenty of articles write localStorage.setItem('token', ...), but OWASP explicitly forbids putting session identifiers there. A token representing login state is one.
Do not store session identifiers in local storage as the data is always accessible by JavaScript.
What is recommended is a cookie.
Cookies can mitigate this risk using the
httpOnlyflag.
A cookie with httpOnly does not appear in document.cookie, so even if XSS injects a script, it cannot read the value out and send it elsewhere. localStorage is always visible to JavaScript, so that defence does not apply.
What it protects against is the value leaking, and no further. If an injected script fires fetch('/api/...', { method: 'POST' }) at the same site, the browser attaches the cookie automatically and the operation goes through. Moving to cookies opens a new front in CSRF, so a sameSite setting is needed (below).
In Nuxt there is one more reason. As seen in chapter 9 and chapter 18, there is no localStorage on the server. Put the token there and the server renders HTML without knowing who is logged in. The first render is always the logged-out state, and the logged-in screen swaps in only after JavaScript runs on the client.
A cookie arrives in the request headers, so the server can render the logged-in state from the very first HTML.
Note, though, that as seen in chapter 18, hitting an API with $fetch during server-side rendering does not forward cookies. To call an authenticated API from the server, go through useRequestFetch() or give useFetch a relative URL.
Issuing the token on the server
httpOnly cookies are set on the server. The login endpoint comes out like this.
The code below issues a fixed string as the token and validates nothing. It is the smallest form for showing where a cookie lives and how it travels.
httpOnly blocks reading, not writing. A user can put any value they like in a cookie, through devtools or curl, so it is no protection unless the server validates the token on every request. In practice you need a signed token (verifying the signature for a JWT) or a lookup against a server-side session. This chapter does not write that implementation. A minimal version of the verifying side is in TypeScript Guide — Express + best practices. It reads the token from the Authorization header, so putting it in a cookie as this chapter does means swapping the source for getCookie(event, 'token') — but the flow of verifying with jwt.verify and pulling out the user carries over as is. Keeping the session on the server is covered by Database Design Guide — Authentication and Authorization.
export default defineEventHandler(async (event) => {
const { email } = await readBody<{ email?: string }>(event)
if (!email) {
throw createError({ statusCode: 400, message: 'email is required' })
}
// return the token in an httpOnly cookie, never in the body
setCookie(event, 'token', 'issued-by-server', {
httpOnly: true,
secure: !import.meta.dev,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return { email }
})
The production build's response header comes out as token=issued-by-server; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax (development builds omit Secure). Do not put the token value in the response body. In the body the caller's JavaScript can receive it, which defeats the point of httpOnly.
sameSite: 'lax' stops the cookie being attached to POSTs from other sites. Cookies are attached automatically to requests to the same site, so without it a form on an external site can act on your behalf (CSRF).
secure is switched on import.meta.dev because a cookie with secure is not sent back over anything but HTTPS. Most browsers make an exception for http://localhost, but open the dev server on a LAN IP or a custom hostname and it stops being sent.
Logging out is just deletion.
export default defineEventHandler((event) => {
deleteCookie(event, 'token', { path: '/' })
return { ok: true }
})
deleteCookie returns token=; Max-Age=0; Path=/. Pass the same path and domain you set it with. A cookie is identified including those two, so a mismatch means a different cookie and it is not removed.
What useCookie can see
useCookie reads and writes cookies as a ref. But for httpOnly cookies, what the server sees and what the client sees differ.
During server-side rendering it reads the request's Cookie header, so it gets the value even for httpOnly. The client reads document.cookie, so httpOnly cookies are invisible. Nuxt's docs likewise warn that "compliant clients will not allow client-side JavaScript to see the cookie in document.cookie".
Ignoring that asymmetry breaks things. Use useCookie('token').value directly in a check and it passes on the server while becoming undefined on post-hydration client navigation, turning a logged-in user away.
Carry "logged in or not" through useState. A useState value goes into the payload and carries over to the client, so both sides make the same decision.
// the .server suffix makes it run on the server only
export default defineNuxtPlugin(() => {
const token = useCookie('token')
// useState values travel to the client in the payload
useState('loggedIn', () => Boolean(token.value))
})
The payload travels to the client, so only put things there that are harmless if leaked. Do not put the token value itself in useState. That defeats the point of httpOnly.
Because this plugin carries the .server suffix, it runs only during server-side rendering. That means it does not run right after logging in, so the state has to be updated on the spot.
import { hasProtocol } from 'ufo'
/**
* Lets through only values that are safe as a post-login destination.
* Delegates the check to hasProtocol, which navigateTo uses internally.
*/
export function safeRedirect(value: unknown) {
return typeof value === 'string' &&
value.startsWith('/') &&
!hasProtocol(value, { acceptRelative: true })
? value
: '/'
}
Anything in app/utils/ is imported automatically, so the page just calls it.
<script setup lang="ts">
const route = useRoute()
const loggedIn = useState('loggedIn', () => false)
const email = ref('')
async function submit() {
await $fetch('/api/login', { method: 'POST', body: { email: email.value } })
// the server-only plugin does not run again until the next full load, so update state here
loggedIn.value = true
// do not hand the query value straight to navigateTo; external URLs throw
await navigateTo(safeRedirect(route.query.redirect))
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="email" type="email" name="email" />
<button type="submit">Log in</button>
</form>
</template>
Do not pass the redirect query straight to navigateTo. An external URL throws, because external: true is absent, and unless submit() catches it you end up logged in with the screen not moving. Put a gate in front that only lets through relative paths starting with /. Do not compare the strings yourself — besides //evil.example.com and /\evil.example.com, a form such as ?redirect=/%20/evil.example.com decodes to / /evil.example.com and also heads to another host. Letting hasProtocol, which navigateTo uses internally, make the call is the reliable route.
Forget loggedIn.value = true and login succeeds while /secret stays unreachable. The cookie is set, so a full page reload gets through — which produces the confusing symptom of "it fixes itself if you reload once".
Logging out is the same: without setting it back to false, the cookie is gone while the screen still shows you logged in.
Protecting navigation
Per-page protection is done with middleware in app/middleware/.
export default defineNuxtRouteMiddleware((to) => {
// httpOnly cookies are unreadable from the client, so share the state through useState
const loggedIn = useState('loggedIn', () => false)
// always return navigateTo; without the return the guard silently passes
if (to.meta.requiresAuth && !loggedIn.value) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})
The page applies it through the definePageMeta from chapter 19. requiresAuth is not a fixed key but a custom one added here.
<script setup lang="ts">
definePageMeta({ middleware: 'auth', requiresAuth: true })
</script>
<template>
<p class="secret">Secret</p>
</template>
Open /secret without the cookie and the server returns an HTTP 302 to /login?redirect=/secret. The decision is made before JavaScript runs on the client, so the protected page's contents never flash into view.
This is for a setup where nuxt build runs a server. Turn it into a static site with nuxt generate, covered below, and there is no server receiving requests, so no 302 happens. Server-only plugins run only at build time, so what gets baked into the payload is the value as of then.
The shape in this chapter presupposes a server running per request. The .server plugin that prepares the decision runs only during server-side rendering, so a setup that merely builds HTML ahead of time and serves it has no initial value. That covers nuxt generate, and equally a nuxt build with ssr: false. If you have pages requiring authentication, choose the shape where a server receives requests (the default nuxt build).
return your navigateTo
The documentation says "Make sure to always use await or return on result of navigateTo when calling it". Inside middleware the return is mandatory. As with the Vue Router guards in chapter 16, returning nothing means it goes through by default.
Without the return the guard silently passes. Inside middleware, navigateTo merely returns an object describing the destination; it does not navigate by itself. Measured on the server, a page missing the return renders with a 200 rather than a 302. The client-side implementation has the same shape, so no navigation happens there either. Doing this in an authentication guard removes the protection entirely, so check for the missing return by eye.
Calling it inside <script setup> needs care too. Calling navigateTo does not stop the code after it. To avoid running the rest, return yourself.
Sending someone to an external URL needs external: true. Without it, it throws. In that case navigation does happen inside middleware, so the "merely returns" story above does not apply.
await navigateTo('https://example.com', { external: true })
What a build produces
nuxt build puts its output in .output/. The contents vary with the preset. With the default node-server preset you get something that runs as a Node server.
.output/
├── nitro.json # metadata such as the preset and the start command
├── public/ # static files served as-is
└── server/
└── index.mjs # the entry point to start
Start it with node .output/server/index.mjs. Copying just .output/ is enough — the dependencies it needs are pulled into .output/server/node_modules/, so the project's node_modules is not required. Changing the preset changes both the entry point and the structure, so checking commands.preview in nitro.json is the reliable route.
nuxt generate puts a static site in .output/public/. Each route gets a directory and an index.html, plus two fallback HTML files.
| File | Role |
|---|---|
200.html | The SPA fallback returned when nothing matched, handing the work to client-side routing |
404.html | The fallback for when you want to return a 404 as a 404 |
Both are pages that render their contents on the client. Error display appears only once error.vue runs in the browser.
Putting it on hosting
With static hosting you configure which file an unmatched URL points at. This is where providers differ, as the documentation says.
Some providers use
200.html, some use404.html, and some let you configure both. Check your hosting provider's static fallback or rewrite settings after deployment.
To upload a generated .output/public/ to Netlify by hand, put one line in public/_redirects.
/* /200.html 200
Older articles sometimes point at /index.html. That is correct for an SPA built with plain Vite, but for Nuxt's nuxt generate it is 200.html. index.html is the top page's content, so opening a deep URL shows the top page instead.
That one line also makes nonexistent URLs return 200, though. To return a 404 as a 404, narrow the range you point at 200.html. In a Git-integrated setup where Netlify's preset runs, Nuxt writes /* /404.html 404 itself. A /* line on your side takes precedence and removes that default 404.
Running as a Node server through nuxt build needs no fallback configuration. Every request reaches the server and is routed there. Deploying to Vercel or Netlify through Git integration has Nuxt detect the provider and pick a preset, so you generally do not need to write a vercel.json yourself.
Summary of this guide
- This chapter covers only how the cookie travels. Issuing and validating the token are omitted.
httpOnlyblocks reading, not writing, so it is no protection unless the server validates on every request - Do not put the token in
localStorage. OWASP forbids it explicitly, and in Nuxt there is nolocalStorageon the server, so the first HTML is always the logged-out state - Issue
httpOnlycookies on the server. Putting the value in the response body defeats the point ofhttpOnly.secureis not sent back over anything but HTTPS, so switch it onimport.meta.dev. WhathttpOnlyprotects is the value leaking, and no further — it cannot close the route where an injected script fires requests at your own site, which is whatsameSiteis for useCookiesees different things on the server and the client. The server reads the request header and getshttpOnlyvalues; the client readsdocument.cookieand does not. UsinguseCookie('token').valuein a check turns users away on client navigation — carry "logged in or not" throughuseStateand the payload, and never the token value itself- Server-only plugins do not run right after logging in. Update
useStateon the spot once$fetchsucceeds. Forgetting produces the "it fixes itself if you reload once" symptom. Logging out is the same: forget to reset it and the cookie is gone while the screen still shows you logged in - Do not pass the
redirectquery straight tonavigateTo. An external URL throws, leaving you logged in with the screen not moving. Besides//, forms starting with/\also head to another host - Protect navigation with middleware in
app/middleware/, and alwaysreturnyournavigateTo. Without the return the guard silently passes and the supposedly protected page renders with a 200 (measured on the server). With it, the server returns a 302 and the page's contents never flash into view - The shape in this chapter presupposes a server running per request. The
.serverplugin preparing the decision does not run, so a setup that merely builds HTML ahead of time (nuxt generate, ornuxt buildwithssr: false) has no initial value. Choose the defaultnuxt buildwhen you have pages requiring authentication - Calling
navigateTodoes not stop the rest of<script setup>.returnyourself to avoid running on. External URLs needexternal: true nuxt buildoutputs to.output/. With the defaultnode-serverpreset that isnitro.json/public//server/, and copying just.output/runs withnode .output/server/index.mjs. Changing the preset changes the structure.nuxt generateputs static files plus200.html/404.htmlin.output/public/- Static hosting fallbacks differ by provider. For Nuxt's SSG, point at
200.htmlrather thanindex.html— but pointing all of/*there makes 404s return 200 too. Running as a Node server needs no such configuration at all
- OWASP — HTML5 Security Cheat Sheet — why session identifiers do not go in Web Storage
- Nuxt —
useCookie— handlinghttpOnly/secure/sameSite - Nuxt —
defineNuxtRouteMiddleware— the middleware signature - Nuxt —
navigateTo— the need forreturn, andexternal - Nuxt — Deployment — the contents of
.output/and200.html/404.html
What to read next
The guide ends here. Looking back, the machinery side rests on chapter 2 (Reactivity) and the design side on chapter 8 (Composables). To go deeper on controlling navigation itself, chapter 16 (Navigation guards) is the continuation.