SvelteKit authentication: email and password with Better Auth

Published
Updated
On this page

Update (April 2026): This post originally used Lucia. Lucia was deprecated in 2024 and is now a learning resource, not a library. The version below uses Better Auth for the session and password work.

Email and password is the most requested sign-in method and also the most tedious one to build from scratch. This tutorial wires it into a SvelteKit app with Better Auth, Postgres, and Drizzle. No hand-rolled password hashing, no session-cookie bookkeeping, just a signup form, a login form, and a protected route.

Why Better Auth and not Lucia

Lucia's author pitches the project as a guide for rolling your own session code now, not a package you install. If you want full control, you can still do that with Arctic and Oslo, but you end up owning a lot of code.

Better Auth gives you a typed auth client, session management, password hashing, and a generated database schema. It's the closest drop-in replacement for Lucia in the SvelteKit ecosystem.

Setting up the SvelteKit project

Scaffold a new SvelteKit app:

bunx sv create sveltekit-better-auth-email
cd sveltekit-better-auth-email
bun install

Setting up the database

Better Auth needs somewhere to store users, sessions, accounts, and verification tokens. We'll use Postgres with Drizzle ORM. The postgres.js driver keeps the dependency footprint small.

bun add drizzle-orm postgres
bun add -D drizzle-kit

Add a DATABASE_URL pointing at a local Postgres database:

echo "DATABASE_URL=postgresql://localhost:5432/sveltekit_better_auth" >> .env

Then create the Drizzle client:

// src/lib/server/db/client.ts

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';

if (!DATABASE_URL) throw new Error('DATABASE_URL is not set');

const client = postgres(DATABASE_URL);
const db = drizzle(client);

export { db };

And a drizzle-kit config at the project root:

// drizzle.config.ts

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');

export default defineConfig({
	schema: './src/lib/server/db/schema.ts',
	dbCredentials: { url: process.env.DATABASE_URL },
	dialect: 'postgresql',
	verbose: true,
	strict: true
});

Add migration scripts to package.json:

{
	"scripts": {
		"db:generate": "drizzle-kit generate",
		"db:migrate": "drizzle-kit migrate",
		"db:push": "drizzle-kit push"
	}
}

The schema file itself doesn't exist yet. Better Auth's CLI will generate it once we've configured the library.

Setting up Better Auth

Install the package:

bun add better-auth

Generate a signing secret and the base URL into .env:

echo "BETTER_AUTH_SECRET=$(bunx @better-auth/cli secret)" >> .env
echo "BETTER_AUTH_URL=http://localhost:5173" >> .env

Now the server config. All we need to turn on email/password is one config block:

// src/lib/server/auth.ts

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { BETTER_AUTH_SECRET, BETTER_AUTH_URL } from '$env/static/private';
import { db } from '$lib/server/db/client';

const auth = betterAuth({
	baseURL: BETTER_AUTH_URL,
	secret: BETTER_AUTH_SECRET,
	database: drizzleAdapter(db, { provider: 'pg' }),
	emailAndPassword: {
		enabled: true,
		minPasswordLength: 8
	},
	plugins: [sveltekitCookies(getRequestEvent)]
});

export { auth };

Two things to flag. sveltekitCookies must be the last plugin in the array. It depends on everything before it to set cookies correctly from server actions. And provider in the Drizzle adapter is 'pg' for Postgres, not 'postgresql'.

Then the client, which the Svelte components will use:

// src/lib/auth-client.ts

import { createAuthClient } from 'better-auth/svelte';

const authClient = createAuthClient();

export { authClient };

Generating the database schema

Better Auth ships a CLI that inspects your config and emits the Drizzle schema for the tables it needs (user, session, account, verification):

bunx @better-auth/cli generate --output src/lib/server/db/schema.ts

Password hashes live on the account table (with providerId: 'credential'), not on user. That separation is what lets you add Google or GitHub sign-in later without migrating the password column.

Then generate and apply the SQL migration. Better Auth's own migrate command only works with the built-in Kysely adapter. For Drizzle, go through drizzle-kit:

bun run db:generate
bun run db:migrate

Hooks

On every request, we want to resolve the current session and make it available as event.locals. Better Auth ships svelteKitHandler to handle the /api/auth/* routes, but it doesn't populate locals for you, so you resolve the session yourself:

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
	const session = await auth.api.getSession({ headers: event.request.headers });
	event.locals.session = session?.session ?? null;
	event.locals.user = session?.user ?? null;

	return svelteKitHandler({ event, resolve, auth, building });
};

And the matching types in src/app.d.ts:

// src/app.d.ts

import type { auth } from '$lib/server/auth';

declare global {
	namespace App {
		interface Locals {
			user: typeof auth.$Infer.Session.user | null;
			session: typeof auth.$Infer.Session.session | null;
		}
	}
}

export {};

The signup page

Sign-up is a form that calls authClient.signUp.email. Better Auth validates the password against minPasswordLength, hashes it with scrypt, writes the user and account rows, and returns an active session.

<!-- src/routes/signup/+page.svelte -->

<script lang="ts">
	import { authClient } from '$lib/auth-client';
	import { goto } from '$app/navigation';

	let name = $state('');
	let email = $state('');
	let password = $state('');
	let error = $state('');
	let loading = $state(false);

	const handleSubmit = async (e: SubmitEvent) => {
		e.preventDefault();
		loading = true;
		error = '';

		const { error: err } = await authClient.signUp.email({ name, email, password });

		loading = false;
		if (err) {
			error = err.message ?? 'Could not create account.';
			return;
		}
		goto('/profile');
	};
</script>

<svelte:head><title>Create an account</title></svelte:head>

<form onsubmit={handleSubmit} class="max-w-md mx-auto p-6 flex flex-col gap-3">
	<h1 class="text-2xl font-bold">Create an account</h1>

	<input bind:value={name} placeholder="Name" required class="border rounded px-3 py-2" />
	<input bind:value={email} type="email" placeholder="Email" required class="border rounded px-3 py-2" />
	<input bind:value={password} type="password" minlength={8} placeholder="Password (min 8 chars)" required class="border rounded px-3 py-2" />

	{#if error}<p class="text-red-600 text-sm">{error}</p>{/if}

	<button type="submit" disabled={loading} class="bg-gray-800 text-white rounded-md px-4 py-2 disabled:opacity-50">
		{loading ? 'Creating…' : 'Create account'}
	</button>

	<p class="text-sm">Already have an account? <a href="/login" class="underline">Log in</a></p>
</form>

Bounce already-signed-in users away with a one-line server load:

// src/routes/signup/+page.server.ts

import { redirect } from '@sveltejs/kit';

export const load = async ({ locals }) => {
	if (locals.user) redirect(302, '/profile');
};

The login page

Almost identical to sign-up. The endpoint is authClient.signIn.email, and there is no name field:

<!-- src/routes/login/+page.svelte -->

<script lang="ts">
	import { authClient } from '$lib/auth-client';
	import { goto } from '$app/navigation';

	let email = $state('');
	let password = $state('');
	let error = $state('');
	let loading = $state(false);

	const handleSubmit = async (e: SubmitEvent) => {
		e.preventDefault();
		loading = true;
		error = '';

		const { error: err } = await authClient.signIn.email({ email, password });

		loading = false;
		if (err) {
			error = err.message ?? 'Invalid email or password.';
			return;
		}
		goto('/profile');
	};
</script>

<svelte:head><title>Log in</title></svelte:head>

<form onsubmit={handleSubmit} class="max-w-md mx-auto p-6 flex flex-col gap-3">
	<h1 class="text-2xl font-bold">Log in</h1>

	<input bind:value={email} type="email" placeholder="Email" required class="border rounded px-3 py-2" />
	<input bind:value={password} type="password" placeholder="Password" required class="border rounded px-3 py-2" />

	{#if error}<p class="text-red-600 text-sm">{error}</p>{/if}

	<button type="submit" disabled={loading} class="bg-gray-800 text-white rounded-md px-4 py-2 disabled:opacity-50">
		{loading ? 'Signing in…' : 'Log in'}
	</button>

	<p class="text-sm">New here? <a href="/signup" class="underline">Create an account</a></p>
</form>

Same redirect-if-authed server load:

// src/routes/login/+page.server.ts

import { redirect } from '@sveltejs/kit';

export const load = async ({ locals }) => {
	if (locals.user) redirect(302, '/profile');
};

Better Auth rate-limits /api/auth/sign-in/email to 3 requests per 10 seconds by default. In production, blown-budget responses come back with a 429 and an X-Retry-After header.

The profile page

Guard the page on the server by checking locals.user. The hook already resolved it for us.

// src/routes/profile/+page.server.ts

import { redirect } from '@sveltejs/kit';

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

	return { user: locals.user };
};

Render the user and wire up sign-out from the same component:

<!-- src/routes/profile/+page.svelte -->

<script lang="ts">
	import { authClient } from '$lib/auth-client';
	import { goto } from '$app/navigation';

	let { data } = $props();

	const signOut = async () => {
		await authClient.signOut({
			fetchOptions: { onSuccess: () => goto('/login') }
		});
	};
</script>

<svelte:head><title>{data.user.name}'s profile</title></svelte:head>

<div class="max-w-5xl mx-auto px-4 py-12 flex flex-col gap-4">
	<h1 class="text-2xl font-bold">Hello {data.user.name}</h1>
	<p>Signed in as {data.user.email}.</p>

	<button onclick={signOut} class="self-start bg-gray-800 text-white rounded-md px-4 py-2">
		Sign out
	</button>
</div>

Migrating from Lucia

If you're updating an existing Lucia-based app, the outline is:

  • Remove lucia, @lucia-auth/adapter-*, and any oslo/crypto helpers. Install better-auth and follow the setup above.

  • The schema has four tables now (user, session, account, verification) instead of two. Run the Better Auth CLI to generate them, then drizzle-kit for the migration.

  • Lucia's user rows map to the new user table. Credentials from Lucia's key table become account rows with providerId: 'credential' and the hash in the password column.

  • Swap API calls: lucia.validateSession(token) becomes auth.api.getSession({ headers }). Session creation happens automatically inside signIn.email and signUp.email.

Existing Argon2id hashes won't verify against Better Auth's default scrypt. Override emailAndPassword.password.verify to accept the old format, then rehash transparently on the next successful login.

Wrapping up

The hard parts of email and password auth (password hashing, session rotation, cookie handling, the race conditions around all of them) live inside Better Auth. What's left for you is a schema, two forms, and a server-side guard.

Next step: add Google sign-in to the same app. Same library, same database tables, one extra provider block.

Happy coding!