What We’re Building

This article builds a fully working authentication system from a blank SvelteKit project. Every file is shown. Every snippet runs. No Lucia, no Auth.js, no database service — we use an in-memory fake DB so you can follow along immediately.

Full Demo: You can find a complete working demo of the authentication context system at https://github.com/StanSkrivanek/thc-auth-context-demo.

By the end you’ll have:

  • Session-cookie auth — httpOnly cookie, server-validated on every request
  • Role-based permissionsadmin:system, write:posts, etc., resolved server-side from a role table
  • Server-side route protection — hook redirects before a single byte of HTML is sent
  • Auth contextcreateAuthContext / getAuthContext for prop-drill-free access everywhere
  • RequirePermission component — declarative UI guards with optional fallback snippet
  • Login / logout pages — form actions with progressive enhancement
  • Dashboard + Admin pages — protected routes demonstrating both layers of defence
Project structure:
══════════════════════════════════════════════════════════
src/
├── app.d.ts                           ← locals type augmentation
├── app.html
├── hooks.server.ts                    ← session validation + route guards
├── lib/
│   ├── server/
│   │   └── db.ts                      ← in-memory fake database
│   ├── auth/
│   │   ├── types.ts                   ← User, Permission, AuthState, AuthContext
│   │   └── auth-context.svelte.ts    ← createAuthContext / getAuthContext
│   └── components/
│       ├── Navigation.svelte
│       └── RequirePermission.svelte
└── routes/
    ├── +layout.server.ts              ← bridges locals → client data
    ├── +layout.svelte                 ← createAuthContext(data.user, data.permissions)
    ├── +page.svelte                   ← public landing
    ├── login/
    │   ├── +page.server.ts
    │   └── +page.svelte
    ├── logout/
    │   └── +page.server.ts
    ├── dashboard/
    │   ├── +page.server.ts            ← server-side auth check
    │   └── +page.svelte
    └── admin/
        ├── +page.server.ts            ← server-side permission check
        └── +page.svelte

Project Bootstrap

npx sv create auth-context-demo
# Choose: SvelteKit minimal · TypeScript: Yes
cd auth-context-demo
npm add lucide-svelte # icons for the demo UI
npm i
npm run dev
<!-- src/app.html — no changes needed from default -->
<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

The Core Principle

Before writing any code, understand the one rule everything flows from:

Server (Authority)                    Client (Display)
══════════════════                    ════════════════
• Validates session cookie            • Context receives already-
• Loads user + permissions              validated data
• Redirects unauthorised requests     • Renders UI based on what
• Returns 403 on bad API calls          server decided
• ENFORCES security                   • Makes ZERO security decisions

Context is just a messenger. The server already decided everything.

The two allowed uses of auth context in components:

<!-- ❌ Route guard — user already received this HTML before $effect ran -->
<script>
	$effect(() => {
		if (!auth.isAuthenticated) goto('/login')
	})
</script>

<!-- ✅ UI guard — hides an element the user can't use anyway -->
{#if auth.hasPermission('delete:posts')}
	<button onclick={deletePost}>Delete</button>
{/if}
<h1>Secret content the user just received</h1>

We’ll come back to this in depth after the implementation.


Step 1: Type Definitions

// src/lib/auth/types.ts

/**
 * Core user identity — who they are.
 * Deliberately contains NO permissions (different lifecycle, see below).
 */
export interface User {
	id: string
	email: string
	name: string
	avatarUrl: string | null
	emailVerified: boolean
	createdAt: Date
}

/**
 * Fine-grained capability strings.
 * Roles are just named bundles of these — keep roles server-side only.
 */
export type Permission =
	| 'read:posts'
	| 'write:posts'
	| 'delete:posts'
	| 'read:comments'
	| 'write:comments'
	| 'delete:comments'
	| 'manage:users'
	| 'manage:roles'
	| 'admin:system'

/**
 * Discriminated union — TypeScript cannot access state.user without
 * first checking status === 'authenticated'. No more forgotten null checks.
 *
 * Note: no 'loading' status. With SvelteKit SSR the server resolves auth
 * before the component tree renders, so auth is never in a loading state.
 */
export type AuthState =
	| { status: 'unauthenticated' }
	| { status: 'authenticated'; user: User; permissions: readonly Permission[] }

/**
 * Everything components receive from getAuthContext().
 * Readonly prevents accidental mutation — context distributes, it doesn't own.
 */
export type AuthContext = Readonly<{
	state: AuthState
	user: User | null
	permissions: readonly Permission[]
	isAuthenticated: boolean
	hasPermission: (permission: Permission) => boolean
	hasAnyPermission: (permissions: readonly Permission[]) => boolean
	hasAllPermissions: (permissions: readonly Permission[]) => boolean
	login: (email: string, password: string) => Promise<void>
	logout: () => Promise<void>
	refresh: () => Promise<void>
}>

Why separate identity from permissions?

┌─────────────────────────┬──────────────────────────────┐
│  User (Identity)        │  Permissions (Authorisation) │
├─────────────────────────┼──────────────────────────────┤
│  id, email, name        │  read:posts, write:posts…    │
│  avatarUrl              │  Derived from roles          │
├─────────────────────────┼──────────────────────────────┤
│  "Who are you?"         │  "What can you do?"          │
│  Rarely changes         │  Changes with role grants    │
│  Cache long-term        │  Invalidate on grants        │
└─────────────────────────┴──────────────────────────────┘

Different lifecycles, different caching, different invalidation triggers. Never embed isAdmin: boolean in the user object — it couples two unrelated concerns and makes role hierarchies impossible.

Why Readonly<> on the context type?

// ❌ Without readonly — TypeScript silently allows this
auth.permissions.push('admin:system') // compiles, breaks app at runtime

// ✅ With readonly — caught at compile time
// Error: Property 'push' does not exist on type 'readonly Permission[]'

Step 2: The Fake In-Memory Database

Teaching Scaffold: Fake Database

The in-memory database used here is intentional — it means you can run every snippet in this article right now with no setup. It resets on server restart and skips password hashing entirely.

The security architecture this article teaches — hooks, httpOnly cookies, server-side route guards, context distribution, permission checks — is fully production-ready and is exactly the pattern you would use with a real database.

When you are ready to replace the fake DB layer with a real one, Real Authentication in SvelteKit with better-auth covers the substitution end-to-end: better-sqlite3 (or any other driver), proper password hashing, and a CLI-generated schema. The hook, client, and context code you write here carries over unchanged.

Let’s define some seed data and a simple API for users, sessions, and permissions. This is the only place in the entire codebase where sessions are validated, users are loaded, or permissions are resolved. The rest of the app relies on this as the single source of truth.

// src/lib/server/db.ts
import { randomBytes } from 'node:crypto'
import type { Permission, User } from '$lib/auth/types'

// ─── Internal shapes ─────────────────────────────────────────────────────────

interface DBUser {
	id: string
	email: string
	name: string
	avatarUrl: string | null
	emailVerified: boolean
	createdAt: Date
	/** ⚠️ Demo only — never store plaintext passwords */
	password: string
}

interface DBRole {
	id: string
	name: string
	permissions: Permission[]
}

interface DBUserRole {
	userId: string
	roleId: string
}

interface DBSession {
	token: string
	userId: string
	expiresAt: Date
}

// ─── Seed data ───────────────────────────────────────────────────────────────

const users: DBUser[] = [
	{
		id: 'user_alice',
		email: 'alice@example.com',
		name: 'Alice Admin',
		avatarUrl: null,
		emailVerified: true,
		createdAt: new Date('2024-01-01'),
		password: 'password123'
	},
	{
		id: 'user_bob',
		email: 'bob@example.com',
		name: 'Bob Editor',
		avatarUrl: null,
		emailVerified: true,
		createdAt: new Date('2024-02-01'),
		password: 'password123'
	}
]

const roles: DBRole[] = [
	{
		id: 'role_admin',
		name: 'Admin',
		permissions: [
			'admin:system',
			'manage:users',
			'manage:roles',
			'read:posts',
			'write:posts',
			'delete:posts',
			'read:comments',
			'write:comments',
			'delete:comments'
		]
	},
	{
		id: 'role_editor',
		name: 'Editor',
		permissions: ['read:posts', 'write:posts', 'read:comments', 'write:comments']
	}
]

const userRoles: DBUserRole[] = [
	{ userId: 'user_alice', roleId: 'role_admin' },
	{ userId: 'user_bob', roleId: 'role_editor' }
]

// Sessions created at runtime — start empty
const sessions: DBSession[] = []

// ─── Public API ──────────────────────────────────────────────────────────────

export const db = {
	users: {
		findByEmail(email: string): DBUser | null {
			return users.find((u) => u.email.toLowerCase() === email.toLowerCase()) ?? null
		},
		findById(id: string): DBUser | null {
			return users.find((u) => u.id === id) ?? null
		},
		/** ⚠️ Demo only — use bcrypt.compare() in production */
		verifyPassword(user: DBUser, password: string): boolean {
			return user.password === password
		}
	},

	sessions: {
		create(userId: string, ttlDays = 1): DBSession {
			const session: DBSession = {
				token: randomBytes(32).toString('hex'),
				userId,
				expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
			}
			sessions.push(session)
			return session
		},
		findByToken(token: string): DBSession | null {
			const s = sessions.find((s) => s.token === token)
			if (!s || s.expiresAt < new Date()) return null
			return s
		},
		deleteByToken(token: string): void {
			const i = sessions.findIndex((s) => s.token === token)
			if (i !== -1) sessions.splice(i, 1)
		}
	},

	permissions: {
		/** Resolves all permissions for a user by collecting from their roles. */
		forUser(userId: string): Permission[] {
			const roleIds = userRoles.filter((ur) => ur.userId === userId).map((ur) => ur.roleId)

			const perms = new Set<Permission>()
			for (const roleId of roleIds) {
				const role = roles.find((r) => r.id === roleId)
				if (role) role.permissions.forEach((p) => perms.add(p))
			}
			return Array.from(perms)
		}
	}
}

/** Maps a DBUser to the public User shape, stripping the password field. */
export function toPublicUser(u: ReturnType<typeof db.users.findById>): User {
	if (!u) throw new Error('toPublicUser: null input')
	return {
		id: u.id,
		email: u.email,
		name: u.name,
		avatarUrl: u.avatarUrl,
		emailVerified: u.emailVerified,
		createdAt: u.createdAt
	}
}

Demo Accounts

EmailPasswordRoleKey permissions
alice@example.compassword123Adminadmin:system, all permissions
bob@example.compassword123Editorread:posts, write:posts, comments

Step 3: Extend SvelteKit Locals

event.locals is SvelteKit’s per-request server-side scratchpad. Every hook, load function, and API route in the same request shares it. TypeScript needs to know the shape before you can write event.locals.user.

// src/app.d.ts
import type { User, Permission } from '$lib/auth/types'

declare global {
	namespace App {
		interface Locals {
			/**
			 * The authenticated user, or null if no valid session.
			 * Set by hooks.server.ts before any load function runs.
			 */
			user: User | null

			/**
			 * Permissions resolved from the user's roles.
			 * readonly — permissions come from the server, not the client.
			 */
			permissions: readonly Permission[]
		}
	}
}

export {}

readonly Permission[] means event.locals.permissions.push(...) is a compile-time error. Permissions are set once by the hook and consumed — never modified downstream.

How locals flows through a request

Browser: GET /dashboard

hooks.server.ts
  ├── Reads session cookie
  ├── db.sessions.findByToken(token) → validates
  ├── db.users.findById(userId) → loads user
  ├── db.permissions.forUser(userId) → loads permissions
  ├── event.locals.user = { id, name, email, … }
  ├── event.locals.permissions = ['read:posts', 'write:posts', …]
  └── Route check: not authenticated? redirect(303, '/login')

+layout.server.ts
  └── return { user: locals.user, permissions: locals.permissions }

+layout.svelte
  └── createAuthContext(data.user, data.permissions)

Any component
  └── getAuthContext().user  →  same validated user, zero prop drilling

Step 4: Server Hook — Session Validation + Route Guards

The hook runs before every load function. It’s the only place real route security happens.

// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit'
import { db, toPublicUser } from '$lib/server/db'

/** Routes that require a valid session. */
const PROTECTED = ['/dashboard', '/settings', '/profile']

/** Routes that additionally require admin:system permission. */
const ADMIN_ONLY = ['/admin']

/** Routes that logged-in users should not see (redirect to dashboard). */
const GUEST_ONLY = ['/login', '/register']

export const handle: Handle = async ({ event, resolve }) => {
	const { pathname } = event.url

	// ── 1. Read session cookie ─────────────────────────────────────────────────
	const token = event.cookies.get('session')

	if (!token) {
		event.locals.user = null
		event.locals.permissions = []
		return applyRouteRules(event, resolve, pathname, null, [])
	}

	// ── 2. Validate session ────────────────────────────────────────────────────
	const session = db.sessions.findByToken(token)

	if (!session) {
		// Expired or invalid — clear the stale cookie
		event.cookies.delete('session', { path: '/' })
		event.locals.user = null
		event.locals.permissions = []
		return applyRouteRules(event, resolve, pathname, null, [])
	}

	// ── 3. Load user + permissions ─────────────────────────────────────────────
	const dbUser = db.users.findById(session.userId)

	if (!dbUser) {
		event.cookies.delete('session', { path: '/' })
		event.locals.user = null
		event.locals.permissions = []
		return applyRouteRules(event, resolve, pathname, null, [])
	}

	const user = toPublicUser(dbUser)
	const permissions = db.permissions.forUser(dbUser.id)

	event.locals.user = user
	event.locals.permissions = permissions

	return applyRouteRules(event, resolve, pathname, user, permissions)
}

async function applyRouteRules(
	event: Parameters<Handle>[0]['event'],
	resolve: Parameters<Handle>[0]['resolve'],
	pathname: string,
	user: import('$lib/auth/types').User | null,
	permissions: readonly import('$lib/auth/types').Permission[]
) {
	const isProtected = PROTECTED.some((p) => pathname.startsWith(p))
	const isAdminOnly = ADMIN_ONLY.some((p) => pathname.startsWith(p))
	const isGuestOnly = GUEST_ONLY.some((p) => pathname.startsWith(p))

	if ((isProtected || isAdminOnly) && !user) {
		const returnUrl = encodeURIComponent(pathname + event.url.search)
		redirect(303, `/login?returnUrl=${returnUrl}`)
	}

	if (isAdminOnly && user && !permissions.includes('admin:system')) {
		// Authenticated but lacks permission — send to dashboard with a note
		redirect(303, '/dashboard?notice=no-admin-access')
	}

	if (isGuestOnly && user) {
		redirect(303, '/dashboard')
	}

	return resolve(event)
}
Why the hook, not +page.server.ts?

You could also add if (!locals.user) redirect(303, '/login') to every protected page’s load function — and for the admin permission check we do exactly that in Step 14 to show the pattern. The hook is a convenient single place for blanket rules; load functions handle route-specific logic. Both are server-side and both prevent HTML from being sent.


Step 5: Root Layout Server Load

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async ({ locals, depends }) => {
	// Tell SvelteKit this load depends on the 'auth:session' key.
	// After login/logout we call invalidate('auth:session') instead of
	// invalidateAll() — only this layout re-runs, not every page load function.
	depends('auth:session')

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

Step 6: Auth Context

// src/lib/auth/auth-context.svelte.ts
import { goto, invalidate } from '$app/navigation'
import { getContext, hasContext, setContext } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import type { AuthContext, AuthState, Permission, User } from './types'

const AUTH_KEY = Symbol('auth')

/**
 * Creates the auth context from server-validated data.
 * Call ONCE in the root +layout.svelte.
 *
 * @param getData - Function that returns current user and permissions from reactive data prop
 */
export function createAuthContext(
	getData: () => { user: User | null; permissions: readonly Permission[] }
): AuthContext {
	/**
	 * Use $derived.by, NOT $state.
	 *
	 * $state would capture a snapshot at creation time.
	 * After invalidate('auth:session') re-runs the layout load and
	 * provides new props, $derived automatically re-evaluates.
	 * $state would silently drift from the server truth.
	 */
	const data = $derived(getData())
	const serverUser = $derived(data.user)
	const serverPermissions = $derived(data.permissions)

	const state = $derived.by<AuthState>(() => {
		if (serverUser) {
			return { status: 'authenticated', user: serverUser, permissions: serverPermissions }
		}
		return { status: 'unauthenticated' }
	})

	const isAuthenticated = $derived(serverUser !== null)

	// O(1) Set-based lookup for hasPermission calls in render loops
	const permSet = $derived(new SvelteSet(serverPermissions))

	function hasPermission(p: Permission): boolean {
		return permSet.has(p)
	}

	function hasAnyPermission(ps: readonly Permission[]): boolean {
		return ps.some((p) => permSet.has(p))
	}

	function hasAllPermissions(ps: readonly Permission[]): boolean {
		return ps.every((p) => permSet.has(p))
	}

	async function login(email: string, password: string): Promise<void> {
		const res = await fetch('/api/auth/login', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ email, password })
		})
		if (!res.ok) {
			const body = await res.json().catch(() => ({}))
			throw new Error(body.message ?? 'Login failed')
		}
		// Re-run only the layout load (not every page's load function)
		await invalidate('auth:session')
		// eslint-disable-next-line svelte/no-navigation-without-resolve
		await goto('/dashboard')
	}

	async function logout(): Promise<void> {
		await fetch('/api/auth/logout', { method: 'POST' })
		await invalidate('auth:session')
		// eslint-disable-next-line svelte/no-navigation-without-resolve
		await goto('/')
	}

	async function refresh(): Promise<void> {
		await invalidate('auth:session')
	}

	/**
	 * Getters — not direct properties.
	 *
	 * If we assigned `user: serverUser` at context creation time, every
	 * consumer would hold a stale snapshot. Getters evaluate the current
	 * reactive value on every read.
	 */
	const ctx: AuthContext = {
		get state() {
			return state
		},
		get user() {
			return serverUser
		},
		get permissions() {
			return serverPermissions
		},
		get isAuthenticated() {
			return isAuthenticated
		},
		hasPermission,
		hasAnyPermission,
		hasAllPermissions,
		login,
		logout,
		refresh
	}

	return setContext(AUTH_KEY, ctx)
}

/**
 * Retrieves the auth context from any descendant component.
 * Throws if called outside the auth context tree.
 */
export function getAuthContext(): AuthContext {
	if (!hasContext(AUTH_KEY)) {
		throw new Error(
			'getAuthContext() called outside the auth context tree. ' +
				'Ensure your root +layout.svelte calls createAuthContext().'
		)
	}
	return getContext<AuthContext>(AUTH_KEY)
}

export const hasAuthContext = () => hasContext(AUTH_KEY)

$derived vs $state — why it matters here

// ❌ $state — captures a snapshot, drifts from server truth
let user = $state<User | null>(serverUser)
// After invalidate('auth:session') re-runs the layout and provides
// a new serverUser prop, this $state stays at the old value.

// ✅ $derived — tracks serverUser reactively
const state = $derived.by<AuthState>(() =>
  serverUser ? { status: 'authenticated', user: serverUser, ... } : { status: 'unauthenticated' }
)
// When serverUser changes (new prop from layout re-run), this re-evaluates.

Step 7: Root Layout — Creating the Context

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { createAuthContext } from '$lib/auth/auth-context.svelte'
	import type { Permission, User } from '$lib/auth/types'
	import Navigation from '$lib/components/Navigation.svelte'

	interface Props {
		data: { user: User | null; permissions: readonly Permission[] }
		children: import('svelte').Snippet
	}

	let { data, children }: Props = $props()

	// One call here — auth is available to every component in the tree
	createAuthContext(() => data)
</script>

<div class="app">
	<Navigation />
	<main class="main-content">
		{@render children()}
	</main>
</div>

<style>
	:global(*, *::before, *::after) {
		box-sizing: border-box;
	}
	:global(body) {
		margin: 0;
		font-family:
			system-ui,
			-apple-system,
			sans-serif;
		line-height: 1.5;
		background: #f8fafc;
		color: #1e293b;
	}

	.app {
		min-height: 100vh;
		display: flex;
		flex-direction: column;
	}

	.main-content {
		flex: 1;
		max-width: 900px;
		width: 100%;
		margin: 0 auto;
		padding: 2rem 1.5rem;
	}
</style>

Step 8: API Routes

Login

// src/routes/api/auth/login/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'

export const POST: RequestHandler = async ({ request, cookies }) => {
	const body = await request.json().catch(() => null)

	if (!body?.email || !body?.password) {
		return json({ message: 'Email and password are required' }, { status: 400 })
	}

	const dbUser = db.users.findByEmail(String(body.email))

	if (!dbUser || !db.users.verifyPassword(dbUser, String(body.password))) {
		// Generic message — prevents user enumeration
		return json({ message: 'Invalid email or password' }, { status: 401 })
	}

	const session = db.sessions.create(dbUser.id, 1)

	cookies.set('session', session.token, {
		path: '/',
		httpOnly: true, // JS cannot read this
		secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
		sameSite: 'lax', // CSRF mitigation
		maxAge: 60 * 60 * 24 // 1 day
	})

	return json({ ok: true, user: toPublicUser(dbUser) })
}

Logout

// src/routes/api/auth/logout/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'

export const POST: RequestHandler = async ({ cookies }) => {
	const token = cookies.get('session')
	if (token) {
		db.sessions.deleteByToken(token)
		cookies.delete('session', { path: '/' })
	}
	return json({ ok: true })
}
httpOnly is the critical attribute

httpOnly: true means the session token is invisible to JavaScript — no document.cookie, no localStorage, no XSS-injected script can read it. It travels only in the Cookie request header, validated by the server hook on every request.


Step 9: Login Page

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

export const load: PageServerLoad = async ({ locals, url }) => {
	// Already logged in — send to dashboard or the intended destination
	if (locals.user) {
		redirect(303, url.searchParams.get('returnUrl') ?? '/dashboard')
	}
	return { returnUrl: url.searchParams.get('returnUrl') }
}

export const actions: Actions = {
	default: async ({ request, cookies, url }) => {
		const form = await request.formData()
		const email = String(form.get('email') ?? '').trim()
		const password = String(form.get('password') ?? '')

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

		const dbUser = db.users.findByEmail(email)

		if (!dbUser || !db.users.verifyPassword(dbUser, password)) {
			return fail(401, { error: 'Invalid email or password', email })
		}

		const session = db.sessions.create(dbUser.id, 1)

		cookies.set('session', session.token, {
			path: '/',
			httpOnly: true,
			secure: process.env.NODE_ENV === 'production',
			sameSite: 'lax',
			maxAge: 60 * 60 * 24
		})

		// Send to where they were trying to go, or default to dashboard
		const returnUrl = url.searchParams.get('returnUrl') ?? '/dashboard'
		redirect(303, returnUrl.startsWith('/') ? returnUrl : '/dashboard')
	}
}
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms'
	import { KeyRound } from 'lucide-svelte'

	interface Props {
		data: { returnUrl: string | null }
		form?: { error?: string; email?: string }
	}

	let { data, form }: Props = $props()
	let isSubmitting = $state(false)
</script>

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

<div class="login-page">
	<div class="login-card">
		<div class="login-header">
			<div class="login-icon">
				<KeyRound size={32} strokeWidth={1.5} />
			</div>
			<h1>Sign in</h1>
			<p>Enter your credentials to continue</p>
		</div>

		{#if form?.error}
			<div class="error-alert" role="alert">{form.error}</div>
		{/if}

		<form
			method="POST"
			use:enhance={() => {
				isSubmitting = true
				return async ({ update }) => {
					await update()
					isSubmitting = false
				}
			}}
		>
			<input type="hidden" name="returnUrl" value={data.returnUrl ?? ''} />

			<div class="field">
				<label for="email">Email</label>
				<input
					type="email"
					id="email"
					name="email"
					value={form?.email ?? ''}
					placeholder="you@example.com"
					autocomplete="email"
					required
					disabled={isSubmitting}
				/>
			</div>

			<div class="field">
				<label for="password">Password</label>
				<input
					type="password"
					id="password"
					name="password"
					placeholder="••••••••"
					autocomplete="current-password"
					required
					disabled={isSubmitting}
				/>
			</div>

			<button type="submit" class="submit-btn" disabled={isSubmitting}>
				{#if isSubmitting}
					<span class="spinner"></span> Signing in…
				{:else}
					Sign in
				{/if}
			</button>
		</form>

		<div class="demo-accounts">
			<p class="demo-title">Demo accounts (password: <code>password123</code>):</p>
			<div class="demo-list">
				<div class="demo-item">
					<code>alice@example.com</code>
					<span class="badge admin">Admin</span>
				</div>
				<div class="demo-item">
					<code>bob@example.com</code>
					<span class="badge editor">Editor</span>
				</div>
			</div>
		</div>
	</div>
</div>

<style>
	.login-page {
		display: flex;
		justify-content: center;
		align-items: center;
		min-height: calc(100vh - 4rem);
		padding: 2rem;
	}

	.login-card {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.75rem;
		padding: 2rem;
		width: 100%;
		max-width: 400px;
		box-shadow:
			0 1px 3px rgba(0, 0, 0, 0.06),
			0 1px 2px rgba(0, 0, 0, 0.04);
	}

	.login-header {
		text-align: center;
		margin-bottom: 1.75rem;
	}
	.login-icon {
		color: orangered;
		margin-bottom: 0.75rem;
		display: flex;
		justify-content: center;
	}
	.login-header h1 {
		font-size: 1.5rem;
		font-weight: 700;
		margin: 0 0 0.25rem;
	}
	.login-header p {
		color: #64748b;
		margin: 0;
		font-size: 0.9rem;
	}

	.error-alert {
		background: #fef2f2;
		border: 1px solid #fecaca;
		color: #dc2626;
		padding: 0.75rem 1rem;
		border-radius: 0.5rem;
		font-size: 0.875rem;
		margin-bottom: 1.25rem;
	}

	form {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
	}
	label {
		font-size: 0.875rem;
		font-weight: 500;
		color: #374151;
	}

	input[type='email'],
	input[type='password'] {
		padding: 0.625rem 0.75rem;
		border: 1px solid #d1d5db;
		border-radius: 0.375rem;
		font-size: 0.9rem;
		width: 100%;
		transition: all 0.15s;
	}

	input:focus {
		outline: none;
		border-color: orangered;
		box-shadow: 0 0 0 3px rgba(255, 69, 0, 0.1);
	}
	input:disabled {
		opacity: 0.6;
	}

	.submit-btn {
		padding: 0.7rem;
		background: orangered;
		color: white;
		border: none;
		border-radius: 0.5rem;
		font-size: 0.95rem;
		font-weight: 600;
		cursor: pointer;
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		margin-top: 0.25rem;
		transition: all 0.15s;
		box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
	}
	.submit-btn:hover:not(:disabled) {
		background: orangered;
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
	}
	.submit-btn:disabled {
		opacity: 0.65;
		cursor: not-allowed;
	}

	.spinner {
		width: 14px;
		height: 14px;
		border: 2px solid rgba(255, 255, 255, 0.35);
		border-top-color: white;
		border-radius: 50%;
		animation: spin 0.7s linear infinite;
	}
	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}

	.demo-accounts {
		margin-top: 1.5rem;
		padding-top: 1.5rem;
		border-top: 1px solid #e2e8f0;
	}

	.demo-title {
		font-size: 0.78rem;
		color: #64748b;
		margin: 0 0 0.6rem;
	}

	.demo-list {
		display: flex;
		flex-direction: column;
		gap: 0.4rem;
	}

	.demo-item {
		display: flex;
		align-items: center;
		justify-content: space-between;
		font-size: 0.8rem;
	}

	code {
		background: #f1f5f9;
		padding: 0.1rem 0.35rem;
		border-radius: 0.25rem;
	}

	.badge {
		font-size: 0.65rem;
		font-weight: 700;
		padding: 0.1rem 0.5rem;
		border-radius: 1rem;
		text-transform: uppercase;
	}
	.badge.admin {
		background: #e0e7ff;
		color: #4338ca;
	}
	.badge.editor {
		background: #dcfce7;
		color: #166534;
	}
</style>

Progressive enhancement: the form works as a plain HTML POST (no JS needed). use:enhance adds smooth behaviour when JS is available — no page reload, error messages appear inline, the submit button shows a spinner. Both paths go through the same server action.


Step 10: Logout Route

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

export const actions: Actions = {
	default: async ({ cookies }) => {
		const token = cookies.get('session')
		if (token) {
			db.sessions.deleteByToken(token)
			cookies.delete('session', { path: '/' })
		}
		redirect(303, '/')
	}
}

We use a form POST — never a <a href="/logout"> link. GET requests can be triggered by <img src> tags on malicious pages, which would silently log the user out (CSRF). POST requires deliberate form submission.


Step 11: Navigation Component

<!-- src/lib/components/Navigation.svelte -->
<script lang="ts">
	import { page } from '$app/stores'
	import { getAuthContext } from '$lib/auth/auth-context.svelte'
	import { Lock, Settings } from 'lucide-svelte'

	const auth = getAuthContext()

	const navLinks = [
		{ href: '/', label: 'Home', public: true },
		{ href: '/dashboard', label: 'Dashboard', public: false }
	]
</script>

<nav class="nav">
	<div class="nav-inner">
		<a href="/" class="nav-brand">
			<Lock size={18} />
			<span>Auth Demo</span>
		</a>

		<div class="nav-links">
			{#each navLinks as link}
				{#if link.public || auth.isAuthenticated}
					<a href={link.href} class="nav-link" class:active={$page.url.pathname === link.href}>
						{link.label}
					</a>
				{/if}
			{/each}

			{#if auth.hasPermission('admin:system')}
				<a
					href="/admin"
					class="nav-link admin-link"
					class:active={$page.url.pathname.startsWith('/admin')}
				>
					<Settings size={14} />
					Admin
				</a>
			{/if}
		</div>

		<div class="nav-user">
			{#if auth.isAuthenticated}
				<div class="user-info">
					<div class="user-avatar">
						{auth.user?.name.charAt(0).toUpperCase()}
					</div>
					<div class="user-details">
						<span class="user-name">{auth.user?.name}</span>
						<span class="user-email">{auth.user?.email}</span>
					</div>
				</div>
				<form method="POST" action="/logout">
					<button type="submit" class="logout-btn">Sign out</button>
				</form>
			{:else}
				<a href="/login" class="login-btn">Sign in</a>
			{/if}
		</div>
	</div>
</nav>

<style>
	.nav {
		background: white;
		border-bottom: 1px solid #e2e8f0;
		position: sticky;
		top: 0;
		z-index: 10;
	}

	.nav-inner {
		max-width: 900px;
		margin: 0 auto;
		padding: 0 1.5rem;
		height: 3.5rem;
		display: flex;
		align-items: center;
		gap: 1.5rem;
	}

	.nav-brand {
		font-weight: 700;
		font-size: 1rem;
		text-decoration: none;
		color: #1e293b;
		flex-shrink: 0;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.nav-links {
		display: flex;
		gap: 0.25rem;
		flex: 1;
	}

	.nav-link {
		padding: 0.35rem 0.75rem;
		border-radius: 0.375rem;
		text-decoration: none;
		font-size: 0.875rem;
		color: #475569;
		transition:
			background 0.15s,
			color 0.15s;
		display: flex;
		align-items: center;
		gap: 0.375rem;
	}
	.nav-link:hover {
		background: #f1f5f9;
		color: #1e293b;
	}
	.nav-link.active {
		background: #ede9fe;
		color: orangered;
		font-weight: 500;
	}

	.admin-link {
		color: orangered !important;
	}
	.admin-link.active {
		background: #f3e8ff !important;
	}

	.nav-user {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		margin-left: auto;
		flex-shrink: 0;
	}

	.user-info {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.user-avatar {
		width: 2rem;
		height: 2rem;
		border-radius: 50%;
		background: orangered;
		color: white;
		display: flex;
		align-items: center;
		justify-content: center;
		font-weight: 700;
		font-size: 0.875rem;
		flex-shrink: 0;
	}

	.user-details {
		display: flex;
		flex-direction: column;
		line-height: 1.2;
	}
	.user-name {
		font-size: 0.8rem;
		font-weight: 600;
		color: #1e293b;
	}
	.user-email {
		font-size: 0.7rem;
		color: #94a3b8;
	}

	.logout-btn {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.375rem;
		padding: 0.3rem 0.7rem;
		font-size: 0.8rem;
		color: #475569;
		cursor: pointer;
		transition: all 0.15s;
	}
	.logout-btn:hover {
		background: #f8fafc;
		border-color: #cbd5e1;
		color: #1e293b;
	}

	.login-btn {
		background: orangered;
		color: white;
		text-decoration: none;
		padding: 0.4rem 0.9rem;
		border-radius: 0.375rem;
		font-size: 0.85rem;
		font-weight: 600;
		transition: all 0.15s;
		box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
	}
	.login-btn:hover {
		background: black;
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
	}
</style>

getAuthContext() requires no props — it reaches up the tree and gets everything it needs from where the root layout set it.


Step 12: RequirePermission Component

<!-- src/lib/components/RequirePermission.svelte -->
<script lang="ts">
	import { getAuthContext } from '$lib/auth/auth-context.svelte'
	import type { Permission } from '$lib/auth/types'

	interface Props {
		/** Single permission or array for multi-permission checks */
		permission: Permission | Permission[]
		/** 'all' (default): every permission required. 'any': at least one. */
		mode?: 'any' | 'all'
		/** Rendered when access is denied. Omit to render nothing. */
		fallback?: import('svelte').Snippet
		children: import('svelte').Snippet
	}

	let { permission, mode = 'all', fallback, children }: Props = $props()

	const auth = getAuthContext()

	const hasAccess = $derived.by(() => {
		const perms = Array.isArray(permission) ? permission : [permission]
		return mode === 'any' ? auth.hasAnyPermission(perms) : auth.hasAllPermissions(perms)
	})
</script>

{#if hasAccess}
	{@render children()}
{:else if fallback}
	{@render fallback()}
{/if}
RequirePermission is UI-only — not security

This component hides elements. It does not protect routes or API endpoints. A user could delete the DOM element and still call /api/posts/delete. Server load functions and API routes must check locals.permissions independently.


Step 13: Public Landing Page

<!-- src/routes/+page.svelte -->
<script lang="ts">
	import { getAuthContext } from '$lib/auth/auth-context.svelte'
	import { Cookie, Lock, Radio, UserCog } from 'lucide-svelte'

	const auth = getAuthContext()
</script>

<svelte:head>
	<title>Auth Demo</title>
</svelte:head>

<div class="landing">
	<h1>SvelteKit Auth + Context Demo</h1>

	<p class="subtitle">
		A fully working authentication system using only SvelteKit's built-in tools — no external auth
		library.
	</p>

	{#if auth.isAuthenticated}
		<div class="welcome-back">
			<p>Welcome back, <strong>{auth.user?.name}</strong>!</p>
			<div class="cta-group">
				<a href="/dashboard" class="btn-primary">Go to Dashboard →</a>
				{#if auth.hasPermission('admin:system')}
					<a href="/admin" class="btn-secondary">Admin Panel →</a>
				{/if}
			</div>
		</div>
	{:else}
		<div class="cta-group">
			<a href="/login" class="btn-primary">Sign In →</a>
		</div>
		<p class="hint">
			Try <code>alice@example.com</code> (admin) or
			<code>bob@example.com</code> (editor) — password <code>password123</code>
		</p>
	{/if}

	<div class="feature-grid">
		<div class="feature">
			<div class="feature-icon">
				<Lock size={24} strokeWidth={1.5} />
			</div>
			<h3>Server-side route guards</h3>
			<p>Hook validates the session cookie before a single byte of HTML is sent.</p>
		</div>
		<div class="feature">
			<div class="feature-icon">
				<UserCog size={24} strokeWidth={1.5} />
			</div>
			<h3>Role-based permissions</h3>
			<p>Roles are resolved server-side from a role table, never computed client-side.</p>
		</div>
		<div class="feature">
			<div class="feature-icon">
				<Radio size={24} strokeWidth={1.5} />
			</div>
			<h3>Context distribution</h3>
			<p>Auth state flows to every component via context — zero prop drilling.</p>
		</div>
		<div class="feature">
			<div class="feature-icon">
				<Cookie size={24} strokeWidth={1.5} />
			</div>
			<h3>httpOnly cookies</h3>
			<p>Session token is invisible to JavaScript. XSS attacks cannot steal it.</p>
		</div>
	</div>
</div>

<style>
	.landing {
		max-width: 720px;
		margin: 0 auto;
	}
	h1 {
		font-size: 2rem;
		font-weight: 800;
		margin: 0 0 0.75rem;
	}
	.subtitle {
		font-size: 1.1rem;
		color: #475569;
		margin: 0 0 2rem;
	}

	.welcome-back p {
		font-size: 1.05rem;
		margin: 0 0 1rem;
	}

	.cta-group {
		display: flex;
		gap: 0.75rem;
		flex-wrap: wrap;
		margin-bottom: 1rem;
	}

	.btn-primary {
		background: orangered;
		color: white;
		text-decoration: none;
		padding: 0.65rem 1.25rem;
		border-radius: 0.5rem;
		font-weight: 600;
		font-size: 0.95rem;
		transition: all 0.15s;
		box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
	}
	.btn-primary:hover {
		background: black;
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
	}

	.btn-secondary {
		background: white;
		color: orangered;
		text-decoration: none;
		padding: 0.65rem 1.25rem;
		border-radius: 0.5rem;
		font-weight: 600;
		font-size: 0.95rem;
		border: 1px solid #e2e8f0;
		transition: all 0.15s;
	}
	.btn-secondary:hover {
		background: #f8fafc;
		border-color: orangered;
	}

	.hint {
		font-size: 0.875rem;
		color: #64748b;
	}
	.hint code {
		background: #f1f5f9;
		padding: 0.1rem 0.35rem;
		border-radius: 0.25rem;
	}

	.feature-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
		gap: 1rem;
		margin-top: 2.5rem;
	}

	.feature {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.75rem;
		padding: 1.25rem;
		transition: all 0.2s;
	}

	.feature:hover {
		border-color: #cbd5e1;
		box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
	}

	.feature-icon {
		color: orangered;
		display: inline-flex;
	}
	.feature h3 {
		font-size: 0.95rem;
		font-weight: 600;
		margin: 0.5rem 0 0.35rem;
	}
	.feature p {
		font-size: 0.875rem;
		color: #475569;
		margin: 0;
	}
</style>

Step 14: Protected Dashboard Page

// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	// The hook already redirected unauthenticated users, but a per-page
	// check is good defence-in-depth and makes intent explicit.
	if (!locals.user) {
		redirect(303, '/login')
	}

	// In production: query DB for this user's data
	return {
		stats: {
			posts: 12,
			comments: 47,
			views: 1_823
		},
		recentActivity: [
			{ id: 1, action: 'Published post', time: '2 hours ago' },
			{ id: 2, action: 'Added comment', time: '5 hours ago' },
			{ id: 3, action: 'Updated settings', time: 'Yesterday' }
		]
	}
}
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
	import { getAuthContext } from '$lib/auth/auth-context.svelte'
	import RequirePermission from '$lib/components/RequirePermission.svelte'
	import { Edit, Eye, FileText, MessageSquare, Settings, Trash2 } from 'lucide-svelte'

	interface Props {
		data: {
			stats: { posts: number; comments: number; views: number }
			recentActivity: Array<{ id: number; action: string; time: string }>
		}
	}

	let { data }: Props = $props()

	const auth = getAuthContext()
</script>

<svelte:head>
	<title>Dashboard</title>
</svelte:head>

<div class="dashboard">
	<header class="page-header">
		<div>
			<h1>Dashboard</h1>
			<p>Welcome back, <strong>{auth.user?.name}</strong></p>
		</div>
		<div class="user-badge">
			<span class="permissions-count">
				{auth.permissions.length} permission{auth.permissions.length === 1 ? '' : 's'}
			</span>
		</div>
	</header>

	<!-- Stats -->
	<div class="stats-grid">
		<div class="stat-card">
			<div class="stat-icon">
				<FileText size={20} strokeWidth={1.5} />
			</div>
			<div class="stat-value">{data.stats.posts}</div>
			<div class="stat-label">Posts</div>
		</div>
		<div class="stat-card">
			<div class="stat-icon">
				<MessageSquare size={20} strokeWidth={1.5} />
			</div>
			<div class="stat-value">{data.stats.comments}</div>
			<div class="stat-label">Comments</div>
		</div>
		<div class="stat-card">
			<div class="stat-icon">
				<Eye size={20} strokeWidth={1.5} />
			</div>
			<div class="stat-value">{data.stats.views.toLocaleString()}</div>
			<div class="stat-label">Views</div>
		</div>

		<!-- This card only shows for users with delete:posts -->
		<RequirePermission permission="delete:posts">
			<div class="stat-card stat-card--accent">
				<div class="stat-icon">
					<Trash2 size={20} strokeWidth={1.5} />
				</div>
				<div class="stat-value">3</div>
				<div class="stat-label">Pending deletions</div>
			</div>
		</RequirePermission>
	</div>

	<!-- Permission-gated sections -->
	<div class="content-grid">
		<section class="section">
			<h2>Recent Activity</h2>
			<ul class="activity-list">
				{#each data.recentActivity as item}
					<li class="activity-item">
						<span class="activity-action">{item.action}</span>
						<span class="activity-time">{item.time}</span>
					</li>
				{/each}
			</ul>
		</section>

		<div class="side-sections">
			<!-- Write access section -->
			<RequirePermission permission="write:posts">
				<section class="section">
					<h2>Quick Actions</h2>
					<div class="action-buttons">
						<button class="action-btn">
							<Edit size={16} />
							New Post
						</button>
						<button class="action-btn">
							<MessageSquare size={16} />
							Add Comment
						</button>
					</div>
				</section>
			</RequirePermission>

			<!-- Admin shortcut — only for admin:system holders -->
			{#snippet adminHint()}
				<section class="section section--muted">
					<p>Need more tools? Ask an admin to grant you additional permissions.</p>
				</section>
			{/snippet}

			<RequirePermission permission="admin:system" fallback={adminHint}>
				<section class="section section--admin">
					<h2>
						<Settings size={18} />
						Admin Shortcut
					</h2>
					<p>You have <code>admin:system</code> — full control available.</p>
					<a href="/admin" class="action-btn action-btn--admin">Go to Admin Panel →</a>
				</section>
			</RequirePermission>
		</div>
	</div>

	<!-- Full permissions list — useful for understanding the system -->
	<section class="section permissions-section">
		<h2>Your Permissions</h2>
		<p class="section-note">
			These were resolved server-side from your roles and forwarded to context. Components read them
			— the server enforces them.
		</p>
		<div class="permission-list">
			{#each auth.permissions as perm}
				<span class="perm-chip">{perm}</span>
			{/each}
		</div>
	</section>
</div>

<style>
	.dashboard {
		max-width: 860px;
	}

	.page-header {
		display: flex;
		justify-content: space-between;
		align-items: flex-start;
		margin-bottom: 2rem;
	}
	.page-header h1 {
		font-size: 1.75rem;
		font-weight: 700;
		margin: 0 0 0.25rem;
	}
	.page-header p {
		color: #475569;
		margin: 0;
	}

	.user-badge {
		display: flex;
		align-items: center;
	}
	.permissions-count {
		background: #ede9fe;
		color: orangered;
		padding: 0.3rem 0.75rem;
		border-radius: 1rem;
		font-size: 0.8rem;
		font-weight: 600;
	}

	.stats-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
		gap: 1rem;
		margin-bottom: 2rem;
	}

	.stat-card {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.75rem;
		padding: 1.25rem;
		text-align: center;
		transition: all 0.2s;
	}
	.stat-card:hover {
		border-color: #cbd5e1;
		box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
	}
	.stat-card--accent {
		border-color: orangered;
		background: #fff5f5;
	}

	.stat-icon {
		color: orangered;
		margin-bottom: 0.5rem;
		display: inline-flex;
		justify-content: center;
	}
	.stat-value {
		font-size: 1.75rem;
		font-weight: 700;
		color: #1e293b;
	}
	.stat-label {
		font-size: 0.8rem;
		color: #64748b;
		margin-top: 0.25rem;
	}

	.content-grid {
		display: grid;
		grid-template-columns: 2fr 1fr;
		gap: 1.5rem;
		margin-bottom: 1.5rem;
	}

	@media (max-width: 640px) {
		.content-grid {
			grid-template-columns: 1fr;
		}
	}

	.section {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.75rem;
		padding: 1.25rem;
	}
	.section h2 {
		font-size: 1rem;
		font-weight: 600;
		margin: 0 0 1rem;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
	.section--muted {
		background: #f8fafc;
	}
	.section--admin {
		border-color: orangered;
		background: #fff5f5;
	}

	.side-sections {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.activity-list {
		list-style: none;
		padding: 0;
		margin: 0;
	}
	.activity-item {
		display: flex;
		justify-content: space-between;
		align-items: center;
		padding: 0.6rem 0;
		border-bottom: 1px solid #f1f5f9;
		font-size: 0.875rem;
	}
	.activity-item:last-child {
		border-bottom: none;
	}
	.activity-action {
		color: #1e293b;
	}
	.activity-time {
		color: #94a3b8;
		font-size: 0.8rem;
	}

	.action-buttons {
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}
	.action-btn {
		padding: 0.5rem 1rem;
		background: #f1f5f9;
		border: 1px solid #e2e8f0;
		border-radius: 0.375rem;
		font-size: 0.875rem;
		cursor: pointer;
		text-align: left;
		text-decoration: none;
		color: #1e293b;
		display: flex;
		align-items: center;
		gap: 0.5rem;
		transition: all 0.15s;
	}
	.action-btn:hover {
		background: #e2e8f0;
		border-color: #cbd5e1;
	}
	.action-btn--admin {
		background: #fff5f5;
		border-color: orangered;
		color: orangered;
	}

	.permissions-section {
		margin-top: 0;
	}
	.section-note {
		font-size: 0.8rem;
		color: #64748b;
		margin: -0.5rem 0 0.75rem;
	}
	.permission-list {
		display: flex;
		flex-wrap: wrap;
		gap: 0.4rem;
	}
	.perm-chip {
		background: #f1f5f9;
		border: 1px solid #e2e8f0;
		padding: 0.2rem 0.6rem;
		border-radius: 1rem;
		font-size: 0.75rem;
		font-family: monospace;
		color: #475569;
	}
</style>

Step 15: Admin Page — Dual Defence

// src/routes/admin/+page.server.ts
import { error, redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/db'

export const load: PageServerLoad = async ({ locals, url }) => {
	// Layer 1: Must be authenticated
	if (!locals.user) {
		redirect(303, `/login?returnUrl=${encodeURIComponent(url.pathname)}`)
	}

	// Layer 2: Must have admin:system permission
	// The hook also guards this route, but this load function check makes
	// the requirement explicit and self-documenting.
	if (!locals.permissions.includes('admin:system')) {
		error(403, 'You need the admin:system permission to access this page.')
	}

	// Admin-only data
	const allUsers = [
		// In production: db.users.findAll()
		{ id: 'user_alice', email: 'alice@example.com', name: 'Alice Admin', role: 'Admin' },
		{ id: 'user_bob', email: 'bob@example.com', name: 'Bob Editor', role: 'Editor' }
	]

	return {
		adminUser: locals.user,
		allUsers,
		systemInfo: {
			nodeVersion: process.version,
			environment: process.env.NODE_ENV ?? 'development',
			sessionCount: 1 // Would be db.sessions.count() in production
		}
	}
}
<!-- src/routes/admin/+page.svelte -->
<script lang="ts">
	import { getAuthContext } from '$lib/auth/auth-context.svelte'
	import { Settings, Shield } from 'lucide-svelte'

	interface UserRow {
		id: string
		email: string
		name: string
		role: string
	}

	interface Props {
		data: {
			adminUser: import('$lib/auth/types').User
			allUsers: UserRow[]
			systemInfo: { nodeVersion: string; environment: string; sessionCount: number }
		}
	}

	let { data }: Props = $props()

	const auth = getAuthContext()
</script>

<svelte:head>
	<title>Admin Panel</title>
</svelte:head>

<div class="admin">
	<header class="page-header">
		<div>
			<h1>
				<Settings size={28} strokeWidth={2} />
				Admin Panel
			</h1>
			<p>Restricted to users with <code>admin:system</code> permission.</p>
		</div>
		<div class="admin-badge">Admin</div>
	</header>

	<!-- Security model explanation -->
	<div class="security-callout">
		<h3>
			<Shield size={20} />
			How this page is protected
		</h3>
		<ol>
			<li>
				<strong>hooks.server.ts</strong> validated the session cookie and checked
				<code>admin:system</code> permission before any HTML was sent. No valid cookie → 303 redirect
				to login. Wrong role → 303 redirect to dashboard.
			</li>
			<li>
				<strong>+page.server.ts load()</strong> re-checks <code>locals.permissions</code>
				as defence-in-depth. If locals are ever misconfigured, this throws a 403 before the component
				renders.
			</li>
			<li>
				<strong>Context (this component)</strong> can call
				<code>auth.hasPermission('admin:system')</code> for UI affordances only — it does NOT enforce
				access. The server already did that.
			</li>
		</ol>
	</div>

	<!-- System info -->
	<section class="section">
		<h2>System Info</h2>
		<dl class="info-grid">
			<div class="info-row">
				<dt>Environment</dt>
				<dd><code>{data.systemInfo.environment}</code></dd>
			</div>
			<div class="info-row">
				<dt>Node.js</dt>
				<dd><code>{data.systemInfo.nodeVersion}</code></dd>
			</div>
			<div class="info-row">
				<dt>Active sessions</dt>
				<dd>{data.systemInfo.sessionCount}</dd>
			</div>
			<div class="info-row">
				<dt>Current admin</dt>
				<dd>{data.adminUser.name} ({data.adminUser.email})</dd>
			</div>
		</dl>
	</section>

	<!-- User table -->
	<section class="section">
		<h2>All Users</h2>
		<div class="user-table">
			<div class="table-header">
				<span>Name</span>
				<span>Email</span>
				<span>Role</span>
				<span>Actions</span>
			</div>
			{#each data.allUsers as user}
				<div class="table-row">
					<span>{user.name}</span>
					<span class="email-cell"><code>{user.email}</code></span>
					<span>
						<span class="role-badge" class:admin={user.role === 'Admin'}>
							{user.role}
						</span>
					</span>
					<span>
						<!-- UI guard: only show delete for users with manage:users -->
						<!-- Server endpoint would also check before executing -->
						{#if auth.hasPermission('manage:users') && user.id !== data.adminUser.id}
							<button class="action-btn-sm" disabled>Remove (demo)</button>
						{/if}
					</span>
				</div>
			{/each}
		</div>
		<p class="table-note">
			The "Remove" button is gated with <code>RequirePermission</code>-style check on the client and
			would hit a server endpoint that re-checks
			<code>locals.permissions.includes('manage:users')</code> before executing.
		</p>
	</section>
</div>

<style>
	.admin {
		max-width: 860px;
	}

	.page-header {
		display: flex;
		justify-content: space-between;
		align-items: flex-start;
		margin-bottom: 1.5rem;
	}
	.page-header h1 {
		font-size: 1.75rem;
		font-weight: 700;
		margin: 0 0 0.25rem;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
	.page-header p {
		color: #475569;
		margin: 0;
		font-size: 0.9rem;
	}
	.page-header p code {
		background: #f1f5f9;
		padding: 0.1rem 0.35rem;
		border-radius: 0.25rem;
	}

	.admin-badge {
		background: black;
		color: white;
		padding: 0.3rem 0.9rem;
		border-radius: 1rem;
		font-size: 0.8rem;
		font-weight: 700;
		text-transform: uppercase;
	}

	.security-callout {
		background: #f0fdf4;
		border: 1px solid #bbf7df;
		border-radius: 0.75rem;
		padding: 1.25rem;
		margin-bottom: 1.5rem;
	}
	.security-callout h3 {
		font-size: 0.95rem;
		font-weight: 600;
		margin: 0 0 0.75rem;
		color: #12462f;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
	.security-callout ol {
		padding-left: 1.25rem;
		margin: 0;
	}
	.security-callout li {
		font-size: 0.875rem;
		color: #166534;
		margin-bottom: 0.4rem;
	}
	.security-callout code {
		background: #c7ffd3;
		padding: 0.1rem 0.35rem;
		border-radius: 0.25rem;
		font-size: 0.8rem;
	}

	.section {
		background: white;
		border: 1px solid #e2e8f0;
		border-radius: 0.75rem;
		padding: 1.25rem;
		margin-bottom: 1rem;
	}
	.section h2 {
		font-size: 1rem;
		font-weight: 600;
		margin: 0 0 1rem;
	}

	.info-grid {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}
	.info-row {
		display: flex;
		gap: 1rem;
		font-size: 0.875rem;
	}
	.info-row dt {
		width: 130px;
		color: #64748b;
		flex-shrink: 0;
	}
	.info-row dd {
		margin: 0;
	}
	.info-row code {
		background: #f1f5f9;
		padding: 0.1rem 0.35rem;
		border-radius: 0.25rem;
	}

	.user-table {
		border: 1px solid #e2e8f0;
		border-radius: 0.5rem;
		overflow: hidden;
		font-size: 0.875rem;
	}
	.table-header,
	.table-row {
		display: grid;
		grid-template-columns: 1.5fr 2fr 1fr 1fr;
		padding: 0.6rem 1rem;
		gap: 0.5rem;
		align-items: center;
	}
	.table-header {
		background: #f8fafc;
		font-weight: 600;
		font-size: 0.78rem;
		color: #64748b;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}
	.table-row {
		border-top: 1px solid #f1f5f9;
	}
	.table-row:hover {
		background: #fafafa;
	}

	.email-cell code {
		font-size: 0.8rem;
	}

	.role-badge {
		background: #f1f5f9;
		color: #475569;
		padding: 0.15rem 0.6rem;
		border-radius: 1rem;
		font-size: 0.75rem;
		font-weight: 500;
	}
	.role-badge.admin {
		background: #fff5f5;
		color: orangered;
	}

	.action-btn-sm {
		padding: 0.25rem 0.6rem;
		background: #fff5f5;
		border: 1px solid orangered;
		border-radius: 0.375rem;
		font-size: 0.75rem;
		color: orangered;
		cursor: pointer;
	}
	.action-btn-sm:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	.table-note {
		font-size: 0.78rem;
		color: #94a3b8;
		margin: 0.75rem 0 0;
		padding-top: 0.75rem;
		border-top: 1px solid #f1f5f9;
	}
	.table-note code {
		background: #f1f5f9;
		padding: 0.1rem 0.3rem;
		border-radius: 0.2rem;
	}
</style>

The Security Model in Full

Server vs Client — What each layer does

Browser request to /admin

hooks.server.ts   ← SECURITY LAYER 1
  ├── token = cookie.get('session')
  ├── session = db.sessions.findByToken(token)   → null = 303 /login
  ├── user = db.users.findById(session.userId)
  ├── permissions = db.permissions.forUser(userId)
  └── permissions.includes('admin:system') ?     → false = 303 /dashboard

+page.server.ts   ← SECURITY LAYER 2 (defence-in-depth)
  └── locals.permissions.includes('admin:system') ? → false = error(403)

+page.svelte / Components   ← UI LAYER (display only)
  └── auth.hasPermission('admin:system') → hides/shows elements
      But this is AFTER the server already decided the user is allowed here.

Why client-side guards don’t work

<!-- ❌ WRONG — the HTML containing "Secret content" is already
     in the HTTP response body when $effect runs -->
<script>
	const auth = getAuthContext()
	$effect(() => {
		if (!auth.isAuthenticated) goto('/login')
	})
</script>

<h1>Secret content — visible to anyone reading the network tab</h1>

$effect runs client-side after the server has sent the page. The user’s browser already received the HTML. A user with JavaScript disabled, or anyone using curl, sees the content immediately.

The dual-check pattern for API endpoints

// src/routes/api/posts/[id]/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const DELETE: RequestHandler = async ({ params, locals }) => {
	// Server MUST check — regardless of what the client did
	if (!locals.permissions.includes('delete:posts')) {
		return json({ error: 'Forbidden' }, { status: 403 })
	}

	// ... delete post
	return json({ ok: true })
}

The client’s {#if auth.hasPermission('delete:posts')} button guard is UX polish — it avoids showing a button that will just fail. The server’s 403 is actual security.


Common Mistakes

1. Module-level auth store (SSR state bleed)

// ❌ WRONG — module-level Svelte store
import { writable } from 'svelte/store'
export const user = writable<User | null>(null)

// Request A sets user = Alice
// Request B (different user) arrives while A's SSR is still in progress
// Both requests share the same module → Request B sees Alice 💥
// ✅ CORRECT — request-scoped context
export function createAuthContext(serverUser: User | null, ...) {
  // Each SSR request creates a fresh component tree with fresh context.
  // No state bleeds between requests.
  return setContext(AUTH_KEY, { get user() { return serverUser } })
}

2. $state instead of $derived for auth values

// ❌ WRONG — $state captures a snapshot at creation time
let user = $state<User | null>(serverUser)
// After invalidate('auth:session') re-runs the layout load and
// provides a new serverUser prop, this $state stays stale.

// ✅ CORRECT — $derived tracks serverUser reactively
const state = $derived.by<AuthState>(() =>
	serverUser
		? { status: 'authenticated', user: serverUser, permissions: serverPermissions }
		: { status: 'unauthenticated' }
)

3. Permissions embedded in the User type

// ❌ WRONG — different lifecycles, different caching needs
interface User {
	id: string
	name: string
	isAdmin: boolean // Changes when roles change
	canWritePosts: boolean // Can't represent role hierarchies
}

// ✅ CORRECT — separate concerns
interface User {
	id: string
	name: string
} // Rarely changes
const permissions = db.permissions.forUser(userId) // Changes with role grants

4. Mutating context directly

// ❌ WRONG — readonly types catch this at compile time
const auth = getAuthContext()
auth.permissions.push('admin:system')
// TypeScript: Property 'push' does not exist on type 'readonly Permission[]'

// ✅ CORRECT — change state server-side, then refresh
await fetch('/api/admin/grant', { method: 'POST', body: JSON.stringify({ permission }) })
await auth.refresh() // invalidate('auth:session') → fresh permissions

5. setContext called after component initialization

<!-- ❌ WRONG — setContext must run during component initialisation -->
<script>
  $effect(() => {
    setContext('auth', ctx) // Too late — $effect runs after mount
  })
</script>

<!-- ✅ CORRECT — top-level script, runs during init -->
<script>
  createAuthContext(data.user, data.permissions)
</script>

Performance Notes

Permission lookup uses a Set internally (permSet = $derived(new Set(serverPermissions))), so hasPermission() is O(1) regardless of how many permissions a user has.

Selective invalidation: depends('auth:session') + invalidate('auth:session') after login/logout re-runs only the layout load function, not every page’s load function. Use invalidateAll() only if you need to refresh everything.

Derived booleans for frequently checked permissions:

// In auth-context.svelte.ts — precompute common checks
const isAdmin        = $derived(permSet.has('admin:system'))
const canManageUsers = $derived(permSet.has('manage:users'))

// Expose on the context object
const ctx: AuthContext = {
  ...
  get isAdmin()        { return isAdmin },
  get canManageUsers() { return canManageUsers },
}

Components call auth.isAdmin instead of auth.hasPermission('admin:system') in every render cycle.


What’s Next

With authentication architecture established, the natural next step is applying the same context patterns to more complex UI. If you would like dive into production ready authentication read Real Authentication in SvelteKit with better-auth.

Finally, the last article demonstrating the power of Context API is a complete multitenant SaaS example building on these auth patterns with subdomain isolation, layered context theming, and per-tenant feature flags, see Multi-Tenant SaaS with Context API.


Key Takeaways

Server-side route protection is mandatory. hooks.server.ts validates the session cookie before any HTML is sent. $effect(() => goto('/login')) runs after HTML is delivered — it cannot protect anything.

Context distributes already-validated state. The hook resolves auth, the layout load bridges it to the client, createAuthContext wraps it in reactive getters. Components call getAuthContext() and consume — they enforce nothing.

$derived not $state for auth values. Server is the source of truth. $derived tracks prop changes when the layout reruns; $state captures a stale snapshot.

Separate identity from permissions. Different lifecycles, different caching, different invalidation. Never embed isAdmin in the user object.

httpOnly cookies are the transport. JavaScript never sees the session token — it travels in Cookie headers, read and validated server-side only.

Dual defence for every protected operation. Hook + load function server-side for routes; client RequirePermission for UX polish. Server endpoint re-checks locals.permissions for every mutating API call.

readonly Permission[] makes misuse a compile error. auth.permissions.push(...) fails at TypeScript compile time, not runtime.


See Also

SvelteKit Documentation