Skip to main content

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 httpOnly cookie on the server, and how it looks from useCookie
  • Blocking navigation with defineNuxtRouteMiddleware and navigateTo. What happens when you forget the return
  • What nuxt build and nuxt generate produce. The roles of 200.html and 404.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 httpOnly flag.

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.

This chapter covers only how the cookie travels

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.

server/api/login.post.ts
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.

server/api/logout.post.ts
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.

app/plugins/auth.server.ts
// 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.

app/utils/safeRedirect.ts
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.

app/pages/login.vue
<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/.

app/middleware/auth.ts
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.

app/pages/secret.vue
<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.

FileRole
200.htmlThe SPA fallback returned when nothing matched, handing the work to client-side routing
404.htmlThe 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 use 404.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. httpOnly blocks 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 no localStorage on the server, so the first HTML is always the logged-out state
  • Issue httpOnly cookies on the server. Putting the value in the response body defeats the point of httpOnly. secure is not sent back over anything but HTTPS, so switch it on import.meta.dev. What httpOnly protects is the value leaking, and no further — it cannot close the route where an injected script fires requests at your own site, which is what sameSite is for
  • useCookie sees different things on the server and the client. The server reads the request header and gets httpOnly values; the client reads document.cookie and does not. Using useCookie('token').value in a check turns users away on client navigation — carry "logged in or not" through useState and the payload, and never the token value itself
  • Server-only plugins do not run right after logging in. Update useState on the spot once $fetch succeeds. 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 redirect query straight to navigateTo. 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 always return your navigateTo. 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 .server plugin preparing the decision does not run, so a setup that merely builds HTML ahead of time (nuxt generate, or nuxt build with ssr: false) has no initial value. Choose the default nuxt build when you have pages requiring authentication
  • Calling navigateTo does not stop the rest of <script setup>. return yourself to avoid running on. External URLs need external: true
  • nuxt build outputs to .output/. With the default node-server preset that is nitro.json / public/ / server/, and copying just .output/ runs with node .output/server/index.mjs. Changing the preset changes the structure. nuxt generate puts static files plus 200.html / 404.html in .output/public/
  • Static hosting fallbacks differ by provider. For Nuxt's SSG, point at 200.html rather than index.html — but pointing all of /* there makes 404s return 200 too. Running as a Node server needs no such configuration at all
Related references

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.