Skip to content

SvelteKit

Complete auth setup for SvelteKit with ideal-auth.

Updated View as Markdown

This guide walks through setting up authentication in a SvelteKit application using ideal-auth. By the end, you will have working login, registration, logout, route protection via hooks, and access to the current user in pages and layouts.

Installation

  1. Install ideal-auth

    bun add ideal-auth
  2. Set the session secret

    Add a secret to your .env file. It must be at least 32 characters.

    .envbash
    IDEAL_AUTH_SECRET="at-least-32-characters-long-secret-here"

    Generate a strong secret:

    bunx ideal-auth secret

SvelteKit provides a cookies object on every RequestEvent. The bridge maps its API to the three functions ideal-auth expects.

src/lib/server/cookies.tsts
import type { Cookies } from '@sveltejs/kit';
import type { CookieBridge } from 'ideal-auth';

export function createCookieBridge(cookies: Cookies): CookieBridge {
  return {
    get(name: string) {
      return cookies.get(name);
    },
    set(name, value, options) {
      cookies.set(name, value, {
        ...options,
        // SvelteKit requires an explicit path
        path: options.path ?? '/',
      });
    },
    delete(name) {
      cookies.delete(name, { path: '/' });
    },
  };
}

Auth setup

Create an auth() factory that accepts a SvelteKit Cookies object and returns an AuthInstance.

src/lib/server/auth.tsts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from '$lib/server/db';
import { IDEAL_AUTH_SECRET } from '$env/static/private';

type User = {
  id: string;
  email: string;
  name: string;
  password: string;
};

export const hash = createHash({ rounds: 12 });

export function auth(cookies: import('@sveltejs/kit').Cookies) {
  const authFactory = createAuth<User>({
    secret: IDEAL_AUTH_SECRET,
    cookie: createCookieBridge(cookies),
    hash,

    async resolveUser(id) {
      return db.user.findUnique({ where: { id } });
    },

    async resolveUserByCredentials(credentials) {
      return db.user.findUnique({
        where: { email: credentials.email },
      });
    },
  });

  return authFactory();
}

Login (form action)

src/routes/login/+page.server.tsts
import { fail, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ cookies }) => {
  const session = auth(cookies);

  if (await session.check()) {
    redirect(303, '/dashboard');
  }
};

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email') as string;
    const password = data.get('password') as string;
    const remember = data.get('remember') === 'on';

    if (!email || !password) {
      return fail(400, { error: 'Email and password are required.', email });
    }

    const session = auth(cookies);
    const success = await session.attempt(
      { email, password },
      { remember },
    );

    if (!success) {
      return fail(400, { error: 'Invalid email or password.', email });
    }

    redirect(303, '/dashboard');
  },
};

Login form

src/routes/login/+page.sveltesvelte
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

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

<h1>Sign in</h1>

{#if form?.error}
  <p class="error">{form.error}</p>
{/if}

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

  <label for="password">Password</label>
  <input id="password" name="password" type="password" required />

  <label>
    <input name="remember" type="checkbox" /> Remember me
  </label>

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

Registration (form action)

src/routes/register/+page.server.tsts
import { fail, redirect } from '@sveltejs/kit';
import { auth, hash } from '$lib/server/auth';
import { db } from '$lib/server/db';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email') as string;
    const name = data.get('name') as string;
    const password = data.get('password') as string;
    const passwordConfirmation = data.get('password_confirmation') as string;

    if (!email || !name || !password) {
      return fail(400, { error: 'All fields are required.', email, name });
    }

    if (password.length < 8) {
      return fail(400, { error: 'Password must be at least 8 characters.', email, name });
    }

    if (password !== passwordConfirmation) {
      return fail(400, { error: 'Passwords do not match.', email, name });
    }

    const existing = await db.user.findUnique({ where: { email } });
    if (existing) {
      return fail(400, { error: 'An account with this email already exists.', email, name });
    }

    const user = await db.user.create({
      data: {
        email,
        name,
        password: await hash.make(password),
      },
    });

    // Log the user in immediately after registration
    const session = auth(cookies);
    await session.login(user);

    redirect(303, '/dashboard');
  },
};

Logout (form action)

src/routes/logout/+page.server.tsts
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ cookies }) => {
    const session = auth(cookies);
    await session.logout();
    redirect(303, '/login');
  },
};

Logout button

src/lib/components/LogoutButton.sveltesvelte
<script lang="ts">
  import { enhance } from '$app/forms';
</script>

<form method="POST" action="/logout" use:enhance>
  <button type="submit">Sign out</button>
</form>

Auth guard (handle hook)

Use a handle hook to protect routes and make the current user available throughout the application via event.locals.

First, declare the types:

src/app.d.tsts
declare global {
  namespace App {
    interface Locals {
      user: {
        id: string;
        email: string;
        name: string;
      } | null;
    }
  }
}

export {};

Then implement the hook:

src/hooks.server.tsts
import { redirect, type Handle } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';

const protectedRoutes = ['/dashboard', '/settings', '/profile'];
const authRoutes = ['/login', '/register'];

export const handle: Handle = async ({ event, resolve }) => {
  const session = auth(event.cookies);
  const user = await session.user();

  // Attach user to locals for access in load functions and actions
  event.locals.user = user
    ? { id: user.id, email: user.email, name: user.name }
    : null;

  const { pathname } = event.url;

  // Redirect unauthenticated users away from protected routes
  if (protectedRoutes.some((route) => pathname.startsWith(route))) {
    if (!user) {
      redirect(303, `/login?callbackUrl=${encodeURIComponent(pathname)}`);
    }
  }

  // Redirect authenticated users away from auth routes
  if (authRoutes.some((route) => pathname.startsWith(route))) {
    if (user) {
      redirect(303, '/dashboard');
    }
  }

  return resolve(event);
};

Getting the current user

In a load function

src/routes/dashboard/+page.server.tsts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  // user is already available from the handle hook
  return {
    user: locals.user,
  };
};

In a page component

src/routes/dashboard/+page.sveltesvelte
<script lang="ts">
  import LogoutButton from '$lib/components/LogoutButton.svelte';
  import type { PageData } from './$types';

  let { data }: { data: PageData } = $props();
</script>

<h1>Welcome, {data.user?.name}</h1>
<p>Email: {data.user?.email}</p>

<LogoutButton />

In a layout

src/routes/dashboard/+layout.server.tsts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  if (!locals.user) {
    redirect(303, '/login');
  }

  return {
    user: locals.user,
  };
};

CSRF protection

SvelteKit has built-in CSRF protection. It automatically checks the Origin header on all form submissions and rejects cross-origin requests with a 403 response. This is enabled by default and requires no additional configuration.

If you need to customize this behavior (for example, to allow specific origins), you can configure it in svelte.config.js:

svelte.config.jsjs
const config = {
  kit: {
    csrf: {
      checkOrigin: true, // this is the default
    },
  },
};

export default config;

Security notes

  • Session secret: Use $env/static/private or $env/dynamic/private to access the secret. Never import secrets into client-side code.
  • HTTPS: In production, set NODE_ENV=production so session cookies are automatically marked Secure.
  • Cookie scope: The default SameSite=Lax and HttpOnly=true settings protect against CSRF and XSS cookie theft.
  • Locals safety: Only attach non-sensitive, serializable data to event.locals. Data in locals is not automatically exposed to the client, but load functions that return it will serialize it.
  • Password hashing: createHash uses bcrypt with SHA-256 pre-hashing for passwords over 72 bytes. The default 12 rounds is suitable for production.
  • Form actions vs API routes: Prefer form actions for auth operations. They benefit from SvelteKit’s built-in CSRF protection and progressive enhancement via use:enhance.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close