Back to guides

How to use Own Auth with Nuxt


Run Own Auth in Nuxt server utilities and Nitro API routes, then store its opaque session token in a Secure HttpOnly cookie. Server middleware verifies sessions before protected handlers use them.

The implementation covers email and password sign-in, protected API routes and pages, magic links, and immediate session revocation.

What you will build

  • A server-only Own Auth instance auto-imported from server/utils/
  • Sign-up and sign-in API routes
  • An opaque session token stored in an HttpOnly cookie
  • Server middleware that validates the session for each request
  • Typed authentication data on the H3 event context
  • Protected API routes and pages
  • Magic-link request and verification routes
  • Sign-out with immediate session revocation

How Own Auth fits into Nuxt

Request flow
Browser form or fetch
-> Nitro API route
-> own-auth
-> Postgres

The browser stores one opaque HttpOnly session token.
Server middleware validates it and adds the result to event.context.
Protected API routes and pages still enforce their own authorization.

Install and migrate

Install Own Auth and a schema validator, then apply the Own Auth migrations to your Postgres database.

Terminal
npm install own-auth zod
npx own-auth migrate
npx own-auth status

Own Auth manages only its own auth tables. If the Nuxt application also uses Prisma, Drizzle, or another migration tool, keep each tool responsible for its own tables.

Configure the server environment

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
APP_URL=http://localhost:3000

Add the token pepper and app URL to Nuxt runtime config so server utilities can read them.

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ownAuthTokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
    appUrl: process.env.APP_URL,
  },
});

Generate the token pepper once, store it with production secrets, and keep it stable. Changing it invalidates existing sessions, authentication links, SMS codes, and API keys. These values are in the private runtimeConfig root, not the public key, so they never reach the browser.

Create the Own Auth server utility

Files in server/utils/ are auto-imported throughout the Nitro server. Create the Own Auth instance here so every API route and middleware can use it without explicit imports.

server/utils/auth.ts
import { createOwnAuth } from 'own-auth';

const config = useRuntimeConfig();

if (!config.appUrl) throw new Error('APP_URL is required');
if (!config.ownAuthTokenPepper) {
  throw new Error('OWN_AUTH_TOKEN_PEPPER is required');
}

export const auth = createOwnAuth({
  tokenPepper: config.ownAuthTokenPepper,
  baseUrl: config.appUrl,
});

Centralize the session cookie

Own Auth returns an opaque session token and stores only its protected hash in Postgres. Put the raw token in a Secure HttpOnly cookie so browser JavaScript cannot read it. H3 provides setCookie, getCookie, and deleteCookie on the event object.

server/utils/session-cookie.ts
import type { H3Event } from 'h3';

const SESSION_COOKIE = 'own_auth_session';

export function getSessionToken(event: H3Event) {
  return getCookie(event, SESSION_COOKIE);
}

export function setSessionCookie(
  event: H3Event,
  token: string,
  expires: Date,
) {
  setCookie(event, SESSION_COOKIE, token, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: !import.meta.dev,
    expires,
  });
}

export function deleteSessionCookie(event: H3Event) {
  deleteCookie(event, SESSION_COOKIE, { path: '/' });
}

Use Secure cookies in production and keep the path consistent when setting and deleting them. SameSite=Lax is a practical default for a first-party Nuxt application while still allowing top-level navigation from an authentication email.

Load the session in server middleware

Nitro server middleware runs before every server request. Read the cookie, verify the database-backed session with Own Auth, and place the result on event.context. Delete an expired or revoked cookie so the browser stops sending it.

server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  const token = getSessionToken(event);
  event.context.currentAuth = token
    ? await auth.getCurrentSession(token)
    : null;

  if (token && !event.context.currentAuth) {
    deleteSessionCookie(event);
  }
});

Declare the context type so every event handler receives a typed currentAuth property.

server/types/auth.d.ts
import type { auth } from '~/server/utils/auth';

declare module 'h3' {
  interface H3EventContext {
    currentAuth: Awaited<ReturnType<typeof auth.getCurrentSession>>;
  }
}

export {};

Create the sign-in API route

Nitro API routes receive the H3 event. Validate the request body before calling Own Auth, set the session cookie, and return the user.

server/api/auth/signin.post.ts
import { AuthError } from 'own-auth';
import { z } from 'zod';

const schema = z.object({
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Enter a valid email and password.',
    });
  }

  let result;
  try {
    result = await auth.signInEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      throw createError({
        statusCode: error.statusCode,
        statusMessage: error.safeMessage,
      });
    }
    throw error;
  }

  setSessionCookie(event, result.sessionToken, result.session.expiresAt);
  return { user: result.user };
});

Wrong emails and wrong passwords both produce Own Auth's generic invalid-credentials error. Preserve that ambiguity so the response does not reveal which accounts exist.

Add sign-up

The sign-up route follows the same pattern. Validate the name, email, and password, call signUpEmailPassword, set the returned session cookie, and return the user. Sign-up may report that an email already exists because the user must resolve that conflict.

server/api/auth/signup.post.ts
import { AuthError } from 'own-auth';
import { z } from 'zod';

const schema = z.object({
  name: z.string().trim().min(1).optional(),
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Enter valid sign-up details.',
    });
  }

  let result;
  try {
    result = await auth.signUpEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      throw createError({
        statusCode: error.statusCode,
        statusMessage: error.safeMessage,
      });
    }
    throw error;
  }

  setSessionCookie(event, result.sessionToken, result.session.expiresAt);
  setResponseStatus(event, 201);
  return { user: result.user };
});

Protect API routes

Each protected API route checks event.context.currentAuth and returns a 401 if the session is missing. Use the verified Own Auth user ID for ownership and authorization checks.

server/api/auth/session.get.ts
export default defineEventHandler((event) => {
  if (!event.context.currentAuth) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
  }

  return {
    user: event.context.currentAuth.user,
    session: event.context.currentAuth.session,
  };
});

Protect pages with route middleware

Use a Nuxt route middleware to redirect unauthenticated users away from protected pages. The middleware calls the session API route and redirects when no session exists.

middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { data } = await useFetch('/api/auth/session');

  if (!data.value) {
    return navigateTo('/signin');
  }
});

Apply the middleware to any protected page with definePageMeta.

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

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

<template>
  <div>
    <h1>Dashboard</h1>
    <p>Welcome, {{ session?.user.name }}</p>
  </div>
</template>

Sign out and revoke the session

Revoke the database session before deleting the browser cookie. Use a POST route because sign-out changes server state and should not be a GET request.

server/api/auth/signout.post.ts
export default defineEventHandler(async (event) => {
  const token = getSessionToken(event);

  try {
    if (token) await auth.signOut(token);
  } finally {
    deleteSessionCookie(event);
  }

  return { signedOut: true };
});

Add magic-link sign-in

Configure an Own Auth email provider, then add one route to request the email and one route to consume the one-time token. Always return the same request confirmation regardless of whether an account exists.

server/api/auth/magic-link/request.post.ts
import { z } from 'zod';

const schema = z.object({
  email: z.string().trim().email(),
});

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Enter a valid email address.',
    });
  }

  await auth.requestMagicLink({ email: parsed.data.email });

  return {
    message: 'If that address can sign in, a link is on its way.',
  };
});
server/api/auth/magic-link/verify.get.ts
import { AuthError } from 'own-auth';

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const token = typeof query.token === 'string' ? query.token : null;

  if (!token) {
    return sendRedirect(event, '/signin?error=invalid_link');
  }

  let result;
  try {
    result = await auth.verifyMagicLink({ token });
  } catch (error) {
    if (error instanceof AuthError) {
      return sendRedirect(event, '/signin?error=invalid_link');
    }
    throw error;
  }

  setSessionCookie(event, result.sessionToken, result.session.expiresAt);
  return sendRedirect(event, '/dashboard');
});

Production checklist

  • Use a Node-compatible Nitro preset and HTTPS in production
  • Keep the Own Auth instance and all secret values in server/ code, never in the public runtimeConfig
  • Store the opaque session token only in a Secure HttpOnly cookie
  • Verify the database session in server middleware and each protected API route
  • Use the verified Own Auth user ID for ownership and authorization checks
  • Use POST routes for sign-in, sign-up, and sign-out
  • Return generic errors for invalid credentials and magic-link requests
  • Never serialize or log passwords, session tokens, provider credentials, or magic-link URLs

FAQ

Can I use Own Auth in a client-only Nuxt app?

Not directly. Own Auth needs a trusted server with Postgres access and a private token pepper. A client-only app must call a separate backend that runs Own Auth. Nuxt with SSR is convenient because Nitro provides that server boundary in the same project.

Should I store the session in a Pinia store or composable?

No. Keep the raw session token in an HttpOnly cookie. A Pinia store or composable may hold non-secret user data fetched from an API route, but browser JavaScript should never receive the token.

Is checking the session in server middleware enough?

It authenticates the request and makes the result available through event.context. Each protected API route must still require that result and enforce the user's authorization for the requested resource.

Conclusion

The Nuxt integration keeps responsibilities clear. Own Auth owns authentication records and revocable database sessions. Nuxt owns API routes, server middleware, cookies, page routing, rendering, and authorization around application data. That separation keeps secrets off the client and avoids duplicating auth logic in the framework layer.