Authentication in Nuxt

A complete auth pattern using Nuxt route middleware, session cookies, and useCookie.

Where auth logic fits in Nuxt

Like Next.js, Nuxt doesn't ship an authentication system out of the box — but its universal-rendering model and file-based route middleware give you a clean, conventional place to enforce "is this visitor allowed to see this page," checked before the page ever renders on the server or the client. Real projects typically lean on a module (@sidebase/nuxt-auth is the most common) to handle the actual credential verification and session/token mechanics correctly; the pattern below is what most of those modules build on underneath, and understanding it directly makes any auth module's configuration much less mysterious.

The three pieces, same as any framework: verifying who someone is (a login endpoint), remembering that they're signed in (a session cookie), and protecting routes (middleware that checks the session before a protected page renders).

Typescript
// server/api/login.post.ts
import { setCookie } from 'h3';

export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event);

  const user = await verifyCredentials(email, password); // your own auth logic
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' });
  }

  const sessionToken = await createSession(user.id);

  setCookie(event, 'session', sessionToken, {
    httpOnly: true, // invisible to client-side JavaScript — mitigates XSS token theft
    secure: true,    // only sent over HTTPS
    sameSite: 'lax', // limits cross-site requests carrying the cookie — mitigates CSRF
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // one week
  });

  return { success: true };
});

setCookie comes from h3, the lightweight HTTP framework Nuxt's server engine (Nitro) is built on — it's available automatically inside any server/api/ handler, with no separate import step needed for the auto-imported request helpers. httpOnly: true is the detail that matters most: it keeps the token unreadable from document.cookie, which is exactly why session state belongs in a cookie rather than localStorage — anything in localStorage is readable by any JavaScript running on the page, including a script injected through an XSS vulnerability.

Route middleware for protecting pages

Nuxt's route middleware — a different mechanism from server middleware, despite the shared name — runs before navigating to a matched page, on both the server (during SSR) and the client (during client-side navigation):

Typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const sessionToken = useCookie('session');

  if (!sessionToken.value) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath }, // remember where they were headed
    });
  }
});

useCookie is a Nuxt composable that reads (and, elsewhere, can set) a cookie in a way that works correctly during both server-side rendering and client-side navigation — reaching for the raw document.cookie instead would fail outright during SSR, since there's no document on the server. navigateTo(...) performs the actual redirect; returning it from the middleware is how a route middleware in Nuxt communicates "don't let this navigation complete as requested."

A page opts into this middleware with definePageMeta:

HTML
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
});
</script>

<template>
  <h1>Dashboard</h1>
</template>

Naming the file middleware/auth.global.ts instead (the .global suffix) runs it automatically before every route, with no definePageMeta needed per page — worth using when most of an app sits behind auth and only a few routes (login, a public marketing page) are the exception, checked with an early return inside the middleware itself instead of applied selectively.

Reading the authenticated user in a page

Once middleware has confirmed a session exists, a page typically still needs the actual user record:

HTML
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' });

const { data: user } = await useFetch('/api/me');
</script>

<template>
  <h1>Welcome back, {{ user?.name }}</h1>
</template>
Typescript
// server/api/me.get.ts
import { getCookie } from 'h3';

export default defineEventHandler(async (event) => {
  const sessionToken = getCookie(event, 'session');
  if (!sessionToken) {
    throw createError({ statusCode: 401, statusMessage: 'Not authenticated' });
  }

  const user = await getSessionUser(sessionToken);
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Session expired' });
  }

  return user;
});

server/api/me.get.ts re-checks the session independently of the page-level middleware — a deliberate defense-in-depth choice, not redundant code. Route middleware protects the page, but the underlying API endpoint is still reachable directly (by URL, by a script, by a stale client), so an endpoint returning sensitive per-user data should always verify the session itself rather than assuming only a properly-middleware-guarded page will ever call it.

A minimal login page

HTML
<!-- pages/login.vue -->
<script setup lang="ts">
const route = useRoute();
const email = ref('');
const password = ref('');
const error = ref<string | null>(null);

async function handleLogin() {
  try {
    await $fetch('/api/login', {
      method: 'POST',
      body: { email: email.value, password: password.value },
    });
    await navigateTo((route.query.redirect as string) || '/dashboard');
  } catch {
    error.value = 'Invalid email or password';
  }
}
</script>

<template>
  <form @submit.prevent="handleLogin">
    <input v-model="email" type="email" placeholder="Email" />
    <input v-model="password" type="password" placeholder="Password" />
    <button type="submit">Log in</button>
    <p v-if="error">{{ error }}</p>
  </form>
</template>

Reading route.query.redirect back out here completes the round trip started by the middleware — a visitor bounced away from /dashboard lands back on /dashboard (not just the homepage) once login succeeds.

Common mistakes

  • Storing the session token in localStorage (or a client-side Pinia store alone) instead of an httpOnly cookie — it becomes readable by any JavaScript on the page, directly exposed to an XSS-based theft.
  • Relying on route middleware alone to protect data, with no independent check inside the corresponding server/api/ handler — the API endpoint is reachable directly regardless of which page's middleware normally guards access to it.
  • Reading document.cookie directly inside a component instead of Nuxt's useCookie composable — it breaks during server-side rendering, since there's no document object on the server.
  • Forgetting the .global suffix (or the per-page definePageMeta call) — route middleware in a plain middleware/ file does nothing at all unless a page explicitly references it or the file is named to run globally.