Back to guides

Own Auth with Nuxt


Call Own Auth from Nitro routes and keep the opaque session token in an HttpOnly cookie. Server middleware verifies the session before protected handlers use it.

Install Own Auth

Terminal
npm install own-auth zod

Apply the database schema

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Create the auth utility

Add a stable token pepper to private runtime config, then create the Own Auth instance lazily from the request config.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ownAuthTokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
  },
});

toPublicUser keeps internal user fields out of Nitro responses.

server/utils/auth.ts
import type { H3Event } from 'h3';
import { createOwnAuth, type User } from 'own-auth';

let authInstance: ReturnType<typeof createOwnAuth> | undefined;

export function getAuth(event: H3Event) {
  if (authInstance) return authInstance;

  const config = useRuntimeConfig(event);
  const tokenPepper = config.ownAuthTokenPepper as string;

  authInstance = createOwnAuth({
    tokenPepper,
  });
  return authInstance;
}

export function toPublicUser({ id, email, name, imageUrl }: User) {
  return { id, email, name, imageUrl };
}

Create the session cookie

Use one H3 helper to read, set, and delete the HttpOnly session cookie.

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: '/' });
}

Load the session in server middleware

Read the cookie in Nitro middleware and call auth.getCurrentSession. Store the result on event.context, and delete the cookie after an expired or revoked session.

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

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

Declare currentAuth on H3EventContext for every event handler.

server/types/auth.d.ts
import type { CurrentSession } from 'own-auth';

declare module 'h3' {
  interface H3EventContext {
    currentAuth: CurrentSession | null;
  }
}

export {};

Create the sign-in API route

Validate the request body and call auth.signInEmailPassword. A complete result sets the session cookie. An MFA result returns its challenge token, methods, and expiry with a successful response.

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.',
    });
  }

  const auth = getAuth(event);
  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;
  }

  if (result.status === 'mfa_required') {
    return {
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    };
  }

  setSessionCookie(event, result.sessionToken, result.session.expiresAt);
  const user = toPublicUser(result.user);
  return { user };
});

Add sign-up

The sign-up route validates the request shape, calls auth.signUpEmailPassword, sets the returned session cookie, and returns the user.

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.',
    });
  }

  const auth = getAuth(event);
  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);
  const user = toPublicUser(result.user);
  return { user };
});

Protect API routes

Require event.context.currentAuth in protected API routes and return only id, email, name, and imageUrl.

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

  const user = toPublicUser(event.context.currentAuth.user);
  return { user };
});

Protect pages with route middleware

Call the session endpoint from route middleware and redirect when it returns no user.

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

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

Apply the middleware 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

Call auth.signOut from a POST route, then delete the browser cookie.

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

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

  return { signedOut: true };
});

Add magic links

Magic-link generation needs the application origin. Add APP_URL to private runtime config, then replace getAuth with the version below.

.env
APP_URL=http://localhost:3000
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ownAuthTokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
    appUrl: process.env.APP_URL,
  },
});
server/utils/auth.ts
export function getAuth(event: H3Event) {
  if (authInstance) return authInstance;

  const config = useRuntimeConfig(event);
  const appUrl = config.appUrl as string;
  const tokenPepper = config.ownAuthTokenPepper as string;

  authInstance = createOwnAuth({
    tokenPepper,
    baseUrl: appUrl,
  });
  return authInstance;
}

After configuring an email provider, add one route to request a magic link and another to verify its token. The request route returns the same confirmation for every email. Complete verification sets the session cookie; MFA returns its challenge payload.

server/api/auth/magic-link/request.post.ts
import { AuthError } from 'own-auth';
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.',
    });
  }

  const auth = getAuth(event);
  try {
    await auth.requestMagicLink({ email: parsed.data.email });
  } catch (error) {
    if (error instanceof AuthError) {
      throw createError({
        statusCode: error.statusCode,
        statusMessage: error.safeMessage,
      });
    }
    throw error;
  }

  return {
    message: 'If that address can sign in, a link is on its way.',
  };
});
server/routes/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');
  }

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

  if (result.status === 'mfa_required') {
    return {
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    };
  }

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