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
npm install own-auth zodSet the database connection, then apply the Own Auth schema.
DATABASE_URL=postgres://user:password@localhost:5432/myappnpx own-auth migrateCreate the auth instance
Add the token pepper, then create the Own Auth instance under $lib/server, which SvelteKit excludes from browser imports.
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secretimport { 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.
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.
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.
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.
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.
<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.
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.
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.
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.
APP_URL=http://localhost:5173import { 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,
});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;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');
};