Back to guides

Own Auth with SvelteKit


Call Own Auth from SvelteKit form actions and keep the opaque session token in an HttpOnly cookie. A server hook verifies the session before protected loads and actions run.

Install Own Auth

Terminal
npm install own-auth zod

Set the database connection, then apply the Own Auth schema.

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

Create the auth instance

Add the token pepper, then create the Own Auth instance under $lib/server, which SvelteKit excludes from browser imports.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
src/lib/server/auth.ts
import { OWN_AUTH_TOKEN_PEPPER } from '$env/static/private';
import { createOwnAuth } from 'own-auth';

export const auth = createOwnAuth({
  tokenPepper: OWN_AUTH_TOKEN_PEPPER,
});

Create the session cookie

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

src/lib/server/session-cookie.ts
import { dev } from '$app/environment';
import type { Cookies } from '@sveltejs/kit';

export const SESSION_COOKIE = 'own_auth_session';

const cookieOptions = {
  path: '/',
  httpOnly: true,
  sameSite: 'lax' as const,
  secure: !dev,
};

export function setSessionCookie(
  cookies: Cookies,
  token: string,
  expires: Date,
) {
  cookies.set(SESSION_COOKIE, token, {
    ...cookieOptions,
    expires,
  });
}

export function deleteSessionCookie(cookies: Cookies) {
  cookies.delete(SESSION_COOKIE, { path: '/' });
}

Load the session in a server hook

Read the cookie in the handle hook and call auth.getCurrentSession. Store the result in event.locals, and delete the cookie after an expired or revoked session.

src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import {
  deleteSessionCookie,
  SESSION_COOKIE,
} from '$lib/server/session-cookie';

export const handle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get(SESSION_COOKIE);
  event.locals.currentAuth = token
    ? await auth.getCurrentSession(token)
    : null;

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

  return resolve(event);
};

Declare currentAuth on App.Locals for server loads, actions, and endpoints.

src/app.d.ts
import type { CurrentSession } from 'own-auth';

declare global {
  namespace App {
    interface Locals {
      currentAuth: CurrentSession | null;
    }
  }
}

export {};

Create the sign-in form action

Validate the form data and call auth.signInEmailPassword. A complete result redirects with the session cookie. An MFA result returns its challenge token, methods, and expiry to the form.

src/routes/signin/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '$lib/server/auth';
import { setSessionCookie } from '$lib/server/session-cookie';
import type { Actions } from './$types';

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

export const actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData();
    const parsed = schema.safeParse(Object.fromEntries(formData));

    if (!parsed.success) {
      return fail(400, { message: 'Enter a valid email and password.' });
    }

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

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

    setSessionCookie(
      cookies,
      result.sessionToken,
      result.session.expiresAt,
    );
    redirect(303, '/dashboard');
  },
} satisfies Actions;

Build the progressively enhanced form

Use use:enhance to keep the form on the server action while avoiding a full-page reload.

src/routes/signin/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms';

  let { form } = $props();
</script>

<form method="POST" use:enhance>
  <label>
    Email
    <input
      name="email"
      type="email"
      autocomplete="email"
      value={form?.email ?? ''}
      required
    />
  </label>

  <label>
    Password
    <input
      name="password"
      type="password"
      autocomplete="current-password"
      required
    />
  </label>

  {#if form?.message}
    <p role="alert">{form.message}</p>
  {/if}
  {#if form?.status === 'mfa_required'}
    <p>Continue with an additional verification method.</p>
  {/if}

  <button type="submit">Sign in</button>
</form>

Add sign-up

The sign-up action validates the request shape, calls auth.signUpEmailPassword, and redirects with the returned session cookie.

src/routes/signup/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '$lib/server/auth';
import { setSessionCookie } from '$lib/server/session-cookie';
import type { Actions } from './$types';

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

export const actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData();
    const parsed = schema.safeParse(Object.fromEntries(formData));

    if (!parsed.success) {
      return fail(400, {
        message: 'Name, email, and password are required.',
      });
    }

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

    setSessionCookie(
      cookies,
      result.sessionToken,
      result.session.expiresAt,
    );
    redirect(303, '/dashboard');
  },
} satisfies Actions;

Protect server loads and mutations

Redirect unauthenticated requests from the server load. Return only id, email, name, and imageUrl to the page.

src/routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals }) => {
  if (!locals.currentAuth) redirect(307, '/signin');

  const { id, email, name, imageUrl } = locals.currentAuth.user;
  return { user: { id, email, name, imageUrl } };
};

Sign out

Call auth.signOut from a form action, then delete the browser cookie.

src/routes/signout/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import {
  deleteSessionCookie,
  SESSION_COOKIE,
} from '$lib/server/session-cookie';
import type { Actions } from './$types';

export const actions = {
  default: async ({ cookies }) => {
    const token = cookies.get(SESSION_COOKIE);

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

    redirect(303, '/signin');
  },
} satisfies Actions;

Add magic links

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

Add the public application origin only when enabling email links. Own Auth uses baseUrl to build /auth/magic-link/verify.

.env
APP_URL=http://localhost:5173
src/lib/server/auth.ts
import { APP_URL, OWN_AUTH_TOKEN_PEPPER } from '$env/static/private';
import { createOwnAuth } from 'own-auth';

export const auth = createOwnAuth({
  tokenPepper: OWN_AUTH_TOKEN_PEPPER,
  baseUrl: APP_URL,
});
src/routes/signin/magic-link/+page.server.ts
import { fail } from '@sveltejs/kit';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '$lib/server/auth';
import type { Actions } from './$types';

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

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const parsed = schema.safeParse(Object.fromEntries(formData));

    if (!parsed.success) {
      return fail(400, { message: 'Enter a valid email address.' });
    }

    try {
      await auth.requestMagicLink({ email: parsed.data.email });
    } catch (error) {
      if (error instanceof AuthError) {
        return fail(error.statusCode, { message: error.safeMessage });
      }
      throw error;
    }

    return {
      message: 'If that address can sign in, a link is on its way.',
    };
  },
} satisfies Actions;
src/routes/auth/magic-link/verify/+server.ts
import { json, redirect } from '@sveltejs/kit';
import { AuthError } from 'own-auth';
import { auth } from '$lib/server/auth';
import { setSessionCookie } from '$lib/server/session-cookie';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url, cookies }) => {
  const token = url.searchParams.get('token');
  if (!token) redirect(303, '/signin?error=invalid_link');

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

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

  setSessionCookie(
    cookies,
    result.sessionToken,
    result.session.expiresAt,
  );
  redirect(303, '/dashboard');
};