The False Sense of Security

A common pattern in SvelteKit tutorials and starter templates puts authentication logic inside a layout load function. The root layout or a section layout checks for a valid session, redirects unauthenticated users to the login page, and attaches user data to the returned object. The page components receive the user through data.user. Everything appears to work.

The problem is that it does not always work. It works for the first hard navigation to any page in that layout. It works when you reload the browser. But for client-side navigations within the same layout, the layout load function frequently does not rerun. An unauthenticated request can slip through to a server load function that assumes authentication was already verified. The redirect that was supposed to fire simply does not.

This is not a subtle edge case. It is the default behavior of SvelteKit’s dependency tracking system, operating exactly as documented and exactly as designed. Load functions only rerun when their tracked inputs change. A navigation from /app/posts to /app/analytics changes the URL, but if the app layout load function does not read url.pathname and does not have the new route in its dependency set, that load function is skipped. The page load runs fresh. The layout load reuses its previous result.

If your entire authentication story lives in the layout load function, you have a real vulnerability in your application and a difficult-to-reproduce bug. Understanding why this happens and what to do about it is the subject of this article.

All examples follow the series convention: no ORMs, no database clients in load functions. Every data access uses plain fetch calls to API endpoints.


Why Layout Guards Silently Fail

To make the vulnerability concrete, consider a simple protected application. There is an app layout at src/routes/app/+layout.server.ts. The developer places an authentication check there. Any route under /app is protected.

// src/routes/app/+layout.server.ts
// A layout guard that appears to protect all routes under /app.
// The guard works correctly on initial hard navigation.
// It does NOT reliably fire during client-side navigation.

import type { LayoutServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

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

	return { user: locals.user }
}

Now consider this sequence of events:

  1. The user hard-navigates to /app/posts. The app layout load runs. locals.user is valid. The redirect does not fire. The page loads.
  2. The user’s session expires on the server. locals.user will now be null on any new server request.
  3. The user clicks a link to /app/analytics. SvelteKit performs a client-side navigation.

In step 3, SvelteKit evaluates which load functions need to rerun. The app layout load function read locals.user in step 1, but locals is not a tracked dependency. The tracked inputs are params, url, route, and parent(). None of those changed in a way the app layout registered interest in. SvelteKit skips the app layout load entirely and reuses the result from step 1.

The page load for /app/analytics does run, because it is a new page. Its server load function receives the current locals, where locals.user is now null. If that page load does not have its own authentication check, it runs without a valid user and returns data to the page component that now has no legitimate session behind it.

The layout holds stale auth data from step 1. The page component renders with that stale user. The session is invalid, but the UI does not know it.

Loading diagram...

The sequence diagram shows the gap clearly. The layout load is skipped during client-side navigation. The page load runs but has no guard. The stale user object from the previous layout load populates the data prop.


Why Load Functions Work This Way

The behavior is intentional. SvelteKit’s dependency tracking exists to make client-side navigation fast. If every layout load reran on every navigation, applications would be slow. The navigation from /app/posts to /app/analytics shares the same root layout and app layout. Those layouts load navigation structure, user profile data, and site configuration. Re-fetching all of that for every click would defeat the purpose of client-side routing.

The dependency tracking system is documented and the rules are precise. A load function reruns when one of its tracked inputs changes: a route param it read, a URL property it accessed, or a dependency it declared with depends(). The locals object is not a tracked dependency. Reading locals.user inside a load function does not register a dependency on the user session. If the session changes between navigations, the load function has no mechanism to know about it.

This is the correct behavior for most layout-loaded data. It would be counterproductive for the navigation load to rerun every time locals changes. The framework has no way to know which changes in locals are meaningful to which load functions. It leaves that judgment to you.

The solution is not to work against this behavior. It is to place authentication enforcement in places that run reliably, and to understand what each placement guarantees.


The Three Strategies

There are three distinct places where authentication logic can live in a SvelteKit application. Each has a different execution guarantee, a different scope of protection, and a different appropriate use case.

Strategy 1: The Handle Hook

The handle hook in src/hooks.server.ts is an interceptor that wraps every server request. “Every server request” means the initial page load, every API call, and every internal fetch SvelteKit makes to run server load functions during client-side navigation. The hook runs before any load function. Its primary responsibility is populating event.locals with validated session data.

This is the only strategy that is unconditionally reliable. Because it runs for every request that reaches the server, it cannot be bypassed by client-side navigation patterns.

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	const sessionId = event.cookies.get('sid')

	if (!sessionId) {
		// No session cookie. locals.user remains undefined.
		// Individual load functions decide how to handle this.
		return resolve(event)
	}

	const response = await event.fetch('/api/session/validate', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ sessionId })
	})

	if (response.ok) {
		const { user } = await response.json()
		event.locals.user = user
	}

	return resolve(event)
}

Note the use of event.fetch rather than the global fetch. Inside the handle hook, event.fetch correctly handles same-origin requests, forwards cookies, and works in all environments. Using the global fetch with a hardcoded http://localhost:5173 URL is a common mistake that works in local development but breaks in every other environment — staging, production, containerised deployments. Always use event.fetch for API calls within the hook.

The hook populates event.locals.user when the session is valid. It leaves it undefined when the session is missing or invalid. Every load function that runs within this request can read event.locals.user with confidence that it reflects the current server-side session state, because the hook ran first and the session was checked during this very request.

When SvelteKit performs a client-side navigation to /app/analytics, it makes a server request to run the +page.server.ts for that route. That request goes through the handle hook. The hook validates the session fresh for this request. locals.user is accurate at the time the page load runs. If the session expired, locals.user is undefined for this request, regardless of what any previous layout load computed.

The hook handles population. Enforcement is a separate concern.

Strategy 2: Page-Level and Layout-Level Server Load Guards

Placing a redirect check inside a +page.server.ts is reliable because the page load runs on every navigation to that specific page, both hard and client-side. Every time the user arrives at /app/analytics, the analytics page load runs and checks locals.user. If the session expired, the check catches it.

// src/routes/app/analytics/+page.server.ts
// A page-level auth guard. Reliable for this specific route.
// Runs on every navigation to /app/analytics regardless of how the user arrived.

import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, url, fetch }) => {
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}

	const response = await fetch(`/api/analytics/dashboard?userId=${locals.user.id}`)

	return {
		analytics: response.ok ? await response.json() : null
	}
}

The limitation is repetition. If the application has twenty routes under /app, each one needs this guard. A developer who adds a new route and forgets the guard has an unprotected endpoint. This is the primary weakness of exclusive reliance on page-level guards.

Layout-level guards in src/routes/app/+layout.server.ts provide broader coverage but with the caveat described in the problem space section. They run reliably on initial navigation to any route under /app. They may be skipped on subsequent client-side navigations within the same layout if no tracked dependency changed.

The practical hybrid is to use both. The layout guard provides broad protection for initial navigation and a reasonable first line of defense. The page guards provide reliable protection for ongoing client-side navigation within the same layout, and they double-check the session state for every server request they handle.

Strategy 3: Using Parent Data for Auth State, with Caveats

A page load function can call await parent() to get the user object that the root layout load fetched. This pattern is common and not inherently wrong, but it must be understood precisely.

// src/routes/app/profile/+page.server.ts
// Using parent() to access user data.

import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ parent, fetch }) => {
	// parent() returns the merged data from ancestor layout loads.
	// This DOES include the user from the root layout server load.
	const { user } = await parent()

	if (!user) {
		redirect(303, '/login')
	}

	// user.id is used to construct the fetch URL.
	const response = await fetch(`/api/users/${user.id}/profile`)
	return { profile: response.ok ? await response.json() : null }
}

Here is the precise limitation: await parent() returns data that was resolved during this specific server request. If the root layout load ran during this request (either because this is a hard navigation or because the root layout’s tracked dependencies changed), the user data is fresh. If the root layout was skipped for this client-side navigation, the user data in parent() comes from the previous run.

During a client-side navigation, the +page.server.ts runs on the server. At the server, the root layout data is reconstructed for the response via the merge process. Whether the root layout actually re-executed depends on the same dependency tracking rules. If it did not re-execute, parent() in the page load returns the previously cached layout output.

This means await parent() does not guarantee fresh session data. It gives you whatever the framework computed for parent data on this request. For reliable auth checks inside page loads, reading locals.user directly is the correct approach. locals is populated fresh by the handle hook for every request. It is always current.

// Preferred: read locals.user directly in page server loads for auth checks.
// locals is populated by the handle hook on every request without exception.

import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, url, fetch }) => {
	// locals.user reflects the session state at the time of THIS request.
	// The handle hook validated the session before this load function ran.
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}

	const response = await fetch(`/api/users/${locals.user.id}/profile`)
	return { profile: response.ok ? await response.json() : null }
}

await parent() is appropriate when you need user data that the layout computed, and where that computation is more complex than what is stored in locals. The initial layout load might enrich the user object with permissions, preferences, or tenant context that is expensive to re-compute. Passing that through parent() is fine, with the understanding that it may reflect a previous request if the layout was skipped.

For the binary auth check (is the user logged in?), use locals.user. It is cheaper, more direct, and always current.


How locals Integrates with Load Functions

The locals object is the primary bridge between the hook layer and the load function layer. Understanding exactly what it is and when it is available prevents confusion about why some patterns work and others do not.

locals is a plain JavaScript object attached to the request event. It starts empty. The handle hook populates it. Every server load function that receives the same event object reads from the same locals. It is scoped to a single HTTP request. Two concurrent requests have separate locals objects.

Loading diagram...

All server load functions within a single request share the same locals. The hook runs first, then the load functions run. This ordering is guaranteed. There is no race condition where a load function might read locals before the hook has populated it.

locals is typed through a declaration in src/app.d.ts. Without the declaration, TypeScript cannot know what fields to expect:

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			user: {
				id: string
				name: string
				email: string
				role: 'user' | 'editor' | 'admin'
			} | null
		}
	}
}

export {}

With this declaration in place, TypeScript knows that locals.user is either a user object or null, and it will prompt you to handle both cases. A load function that reads locals.user without checking for null will produce a type error if the downstream code treats it as non-nullable. This is the type system enforcing the pattern: always check before trusting.

// src/routes/app/+layout.server.ts
// TypeScript knows locals.user might be null.
// Failing to check before accessing user.id would be a type error.

import type { LayoutServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: LayoutServerLoad = async ({ locals, url }) => {
	// Without this check, TypeScript would complain: 'locals.user' is possibly null.
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}

	// After the null check, TypeScript narrows the type.
	// locals.user is now known to be non-null below this point.
	return {
		user: {
			id: locals.user.id,
			name: locals.user.name,
			role: locals.user.role
		}
	}
}

The type narrowing after the null check is not cosmetic. It means every downstream reference to locals.user below the guard is guaranteed by the type system to be non-null. TypeScript will catch any attempt to access the user without a preceding check.


Building a requireLogin() Utility with getRequestEvent()

The repeated pattern of checking locals.user and calling redirect(303, '/login') across dozens of load functions is friction. Every new route needs the same boilerplate. If the login URL changes, every file needs updating. If you want to add rate limiting or logging to auth failures, you update dozens of files.

SvelteKit provides getRequestEvent() from $app/server to solve exactly this problem. It returns the current RequestEvent from any server-side function that is executing within the context of a request. This means you can write a utility function that reads locals and calls redirect without needing the event passed as an argument. Any server load function can call the utility without threading the event object through the call chain.

// src/lib/server/auth.ts
// Reusable auth utilities using getRequestEvent().
// Call these from any server load function or server-side helper.

import { getRequestEvent } from '$app/server'
import { redirect, error } from '@sveltejs/kit'

/**
 * Requires the current request to have an authenticated user.
 * Redirects to /login with a returnTo parameter if not.
 * Returns the validated user object on success.
 */
export function requireLogin() {
	const event = getRequestEvent()
	const user = event.locals.user

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

	return user
}

/**
 * Requires the current request to have an authenticated user with a specific role.
 * Redirects to /login if not authenticated.
 * Throws a 403 error if authenticated but lacks the required role.
 */
export function requireRole(role: 'editor' | 'admin') {
	const user = requireLogin()

	if (user.role !== role) {
		error(403, `This page requires the ${role} role.`)
	}

	return user
}

/**
 * Returns the current user without requiring authentication.
 * Returns null if not authenticated.
 * Use this for pages that adapt their content based on auth state
 * but do not require it.
 */
export function getOptionalUser() {
	const event = getRequestEvent()
	return event.locals.user
}

With these utilities in place, a protected load function becomes:

// src/routes/app/posts/+page.server.ts
// Before: 6 lines of boilerplate per protected route.
// After: one function call.

import type { PageServerLoad } from './$types'
import { requireLogin } from '$lib/server/auth'

export const load: PageServerLoad = async ({ url, fetch }) => {
	const user = requireLogin()
	// If not logged in, requireLogin() calls redirect() and never returns.
	// Execution only reaches here with a valid, non-null user.

	const status = url.searchParams.get('status') ?? 'published'
	const page = Number(url.searchParams.get('page') ?? '1')

	const response = await fetch(`/api/users/${user.id}/posts?status=${status}&page=${page}`)

	return {
		posts: response.ok ? await response.json() : [],
		currentStatus: status,
		currentPage: page
	}
}

Notice that the event object is not passed to requireLogin(). The function calls getRequestEvent() internally to retrieve it. This removes the coupling between the utility function’s interface and SvelteKit’s load event shape. The utility can be called from nested helper functions too, as long as those functions are executing within the same server request context.

The role-based utility works the same way:

// src/routes/admin/users/+page.server.ts

import type { PageServerLoad } from './$types'
import { requireRole } from '$lib/server/auth'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ url, fetch }) => {
	// requireRole() calls requireLogin() internally.
	// If not logged in: redirects to /login.
	// If logged in but not admin: throws 403.
	// If admin: returns the admin user.
	const adminUser = requireRole('admin')

	const page = Number(url.searchParams.get('page') ?? '1')
	const response = await fetch(`/api/admin/users?page=${page}&perPage=25`)

	if (!response.ok) {
		error(500, 'Failed to load user list')
	}

	const { users, total } = await response.json()

	return {
		users,
		currentPage: page,
		total,
		adminName: adminUser.name
	}
}

getRequestEvent() scope and limitations

getRequestEvent() is available from SvelteKit 2.20.0. It works within server-only request contexts: hooks.server.ts, +page.server.ts, +layout.server.ts, +server.ts API routes, and any function called from those contexts within the same request lifecycle.

It does not work in universal load functions (+page.ts / +layout.ts). Universal load functions are not a supported context regardless of whether they happen to be running on the server during SSR — they are not server-only modules, so getRequestEvent() is not available to them. It also does not work at module initialization time, during build, or in any code that executes outside of an active request.

If getRequestEvent() is called outside a valid request context, it throws a clear error rather than returning a stale or undefined event. This makes misuse obvious rather than silent.

One additional caveat: in environments that do not support AsyncLocalStorage, getRequestEvent() must be called synchronously, meaning not after an await. Most modern server runtimes (Node.js, Deno, Bun, Cloudflare Workers) support AsyncLocalStorage, so in practice this restriction rarely applies. But if you are targeting an unusual deployment environment, call getRequestEvent() at the top of the function before any await.

// This would throw at build time or in a module that initializes outside a request.
// getRequestEvent() is safe to call, but call it where you know a request is active.

// Safe: inside a load function, hook, or function called from those.
export const load: PageServerLoad = async () => {
	const user = requireLogin() // calls getRequestEvent() inside a request context. Safe.
	return { user }
}

// Unsafe: at module top level, which runs during server initialization.
// const user = requireLogin() // Would throw. No request context at this point.

The rule of thumb: getRequestEvent() is safe anywhere the framework has started handling a specific HTTP request and has not yet sent the final response.


When to Use Hooks vs Load Function Guards

The two layers serve different purposes. Using them correctly means placing each concern in the layer that is suited for it, not duplicating logic across both.

The handle hook is for population, not enforcement. Its job is to take the raw HTTP request, extract the session identifier, validate it, and attach the resulting user to locals. This should happen for every request, without exception. The hook does not redirect unauthenticated users. It simply reflects the honest state of the session in locals.

Load function guards are for enforcement. They read from locals and decide what to do with what they find. A protected route redirects. A route that adapts to auth state renders differently based on whether locals.user is present. A route that is entirely public ignores locals.user. The choice is made per-load-function.

This separation has a concrete benefit: the session validation cost is paid exactly once per request, in the hook, regardless of how many load functions run in that request’s lifetime. If you put session validation in every load function instead of the hook, each protected load function that runs for a request makes its own session validation call. A three-level layout hierarchy could make three validation calls for a single page load, all returning the same result.

Request to /app/analytics:

  hooks.server.ts: handle()       One session validation call.   locals.user populated.
  +layout.server.ts: load()       Reads locals.user.             Skipped or runs auth check.
  +layout.server.ts: load()       Reads locals.user.             Runs auth check.
  +page.server.ts: load()         Reads locals.user.             Runs auth check.

  Total session validation calls: 1 (in the hook)
  Auth checks in load functions:  2 or 3 (redirect or continue based on locals)

The auth checks in load functions are cheap. They read a value already in memory and either call redirect or continue. The expensive operation is the session validation, and the hook ensures it happens only once.

The question of whether to guard at the layout level, the page level, or both comes down to the security requirements of your application:

For applications where the layout itself returns sensitive data (an admin user’s profile, a tenant-specific config), the layout load must guard. That data should not be returned for unauthenticated requests. Use the layout guard for this case even knowing it may be skipped on client-side navigations, because:

  1. On client-side navigations, the page server load runs and its guard fires.
  2. The layout’s sensitive data is cached from the previous run. If the session was valid then, the data was legitimately produced.
  3. If the session expired and the page server load catches it and redirects, the layout data in cache is never sent to the browser in a new response.

For applications where the page load fetches data that requires a valid user (constructing a URL with locals.user.id, for example), the page load must guard. It cannot proceed without a valid user. The page guard is not optional here regardless of what the layout does.

The recommended approach for most applications:

hooks.server.ts:         Validate session. Populate locals.user. Never redirect.
+layout.server.ts:       Guard if the layout itself returns sensitive data.
                         Otherwise, skip the guard and let page loads handle it.
+page.server.ts:         Guard every protected page. Use requireLogin() or a
                         direct locals.user check. This is the reliable layer.

Practical Example: A Protected Application with Role Enforcement

The following complete example shows all three layers working together: the hook populates locals, the app layout provides a light guard for its own sensitive output, and individual page loads use requireLogin() and requireRole() for precise enforcement.

App declaration

// src/app.d.ts

declare global {
	namespace App {
		interface Locals {
			user: {
				id: string
				name: string
				email: string
				role: 'user' | 'editor' | 'admin'
				avatarUrl: string
			} | null
		}
	}
}

export {}

The handle hook

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	const sessionId = event.cookies.get('sid')

	if (!sessionId) {
		return resolve(event)
	}

	const response = await event.fetch('/api/session/validate', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ sessionId })
	})

	if (response.ok) {
		const { user } = await response.json()
		event.locals.user = user
	}

	return resolve(event)
}

The auth utilities

// src/lib/server/auth.ts

import { getRequestEvent } from '$app/server'
import { redirect, error } from '@sveltejs/kit'

export function requireLogin() {
	const event = getRequestEvent()

	if (!event.locals.user) {
		const returnTo = encodeURIComponent(event.url.pathname + event.url.search)
		redirect(303, `/login?returnTo=${returnTo}`)
	}

	return event.locals.user
}

export function requireRole(role: 'editor' | 'admin') {
	const user = requireLogin()

	if (user.role !== role) {
		error(403, `Access requires the ${role} role.`)
	}

	return user
}

export function getOptionalUser() {
	return getRequestEvent().locals.user
}

The app section layout

// src/routes/app/+layout.server.ts
// Guards the layout's own output (the user nav data).
// Also provides a first line of defense on initial hard navigation.

import type { LayoutServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: LayoutServerLoad = async ({ locals, url }) => {
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}

	return {
		user: {
			id: locals.user.id,
			name: locals.user.name,
			avatarUrl: locals.user.avatarUrl,
			role: locals.user.role
		}
	}
}

A standard protected page

// src/routes/app/posts/+page.server.ts
// Page-level guard using the requireLogin() utility.
// Reliable on every navigation: hard and client-side.

import type { PageServerLoad } from './$types'
import { requireLogin } from '$lib/server/auth'

export const load: PageServerLoad = async ({ url, fetch }) => {
	const user = requireLogin()

	const status = url.searchParams.get('status') ?? 'published'
	const page = Number(url.searchParams.get('page') ?? '1')

	const response = await fetch(
		`/api/users/${user.id}/posts?status=${status}&page=${page}&perPage=20`
	)

	return {
		posts: response.ok ? await response.json() : [],
		currentPage: page,
		currentStatus: status
	}
}

An admin-only page

// src/routes/app/admin/users/+page.server.ts

import type { PageServerLoad } from './$types'
import { requireRole } from '$lib/server/auth'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ url, fetch }) => {
	requireRole('admin')

	const page = Number(url.searchParams.get('page') ?? '1')

	const response = await fetch(`/api/admin/users?page=${page}&perPage=25`)

	if (!response.ok) {
		error(500, 'Failed to load users')
	}

	const { users, total } = await response.json()

	return {
		users,
		currentPage: page,
		total,
		totalPages: Math.ceil(total / 25)
	}
}

A public page that adapts to auth state

// src/routes/blog/[slug]/+page.server.ts
// Public route. No guard. Adapts its output based on whether
// a user is logged in, using getOptionalUser().

import type { PageServerLoad } from './$types'
import { getOptionalUser } from '$lib/server/auth'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, fetch }) => {
	const user = getOptionalUser()

	const postPromise = fetch(`/api/blog/posts/${params.slug}`)
	const historyPromise = user
		? fetch(`/api/users/${user.id}/reading-history/check?slug=${params.slug}`)
		: Promise.resolve(null)

	const [postResponse, historyResponse] = await Promise.all([postPromise, historyPromise])

	if (!postResponse.ok) {
		error(404, 'Post not found')
	}

	const post = await postResponse.json()
	const hasReadBefore = historyResponse?.ok ? (await historyResponse.json()).hasRead : false

	return {
		post,
		isAuthenticated: user !== null,
		hasReadBefore
	}
}

The app layout component

<!-- src/routes/app/+layout.svelte -->

<script lang="ts">
	import type { LayoutProps } from './$types'

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

	const initials = $derived(
		data.user.name
			.split(' ')
			.map((part: string) => part[0])
			.join('')
			.toUpperCase()
			.slice(0, 2)
	)
</script>

<div class="app-shell">
	<nav class="app-nav">
		<a href="/app" class="app-brand">Hackpile</a>

		<ul class="app-nav-links">
			<li><a href="/app/posts">Posts</a></li>
			<li><a href="/app/analytics">Analytics</a></li>
			{#if data.user.role === 'admin'}
				<li><a href="/app/admin/users">Admin</a></li>
			{/if}
		</ul>

		<div class="app-user">
			{#if data.user.avatarUrl}
				<img src={data.user.avatarUrl} alt={data.user.name} class="user-avatar" />
			{:else}
				<span class="user-initials" aria-label={data.user.name}>{initials}</span>
			{/if}
			<span class="user-name">{data.user.name}</span>
		</div>
	</nav>

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

The admin link visibility is a UI guard, not an access control mechanism. The component reads data.user.role and shows or hides the admin link accordingly. This does not protect the admin routes. If a user with a non-admin role navigates directly to /app/admin/users, the page server load calls requireRole('admin'), which calls requireLogin() then checks the role, and either redirects or throws a 403 depending on the auth state. The UI merely avoids surfacing a link that would immediately fail.

This distinction between UI guards and route guards applies universally. Hiding a button, link, or section based on role is appropriate client-side behavior. It improves the user experience. It is not access control. Access control happens on the server, in the load function, where the server-validated locals.user is available.


Common Mistakes and Anti-Patterns

Relying exclusively on layout load guards without page guards

// Avoid: a layout that provides all auth protection,
// with page loads that assume authentication was verified.

// src/routes/app/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals, url }) => {
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}
	return { user: locals.user }
}

// src/routes/app/analytics/+page.server.ts
// No guard here. Assumes the layout protected us. Vulnerable on
// client-side navigations if the session expires mid-session.
export const load: PageServerLoad = async ({ locals, fetch }) => {
	// locals.user might be null here on a client-side navigation
	// where the layout load was skipped. This line would throw.
	const response = await fetch(`/api/analytics/${locals.user!.id}/summary`)
	return { analytics: await response.json() }
}
// Preferred: page loads use requireLogin() (or a direct locals check)
// to enforce auth regardless of what the layout did.

export const load: PageServerLoad = async ({ fetch }) => {
	const user = requireLogin()

	const response = await fetch(`/api/analytics/${user.id}/summary`)
	return { analytics: response.ok ? await response.json() : null }
}

Treating layout data as authoritative auth state

// Avoid: reading auth state from parent() and treating it as reliable
// for security decisions within a server load.

export const load: PageServerLoad = async ({ parent, fetch }) => {
	const { user } = await parent()

	// parent() may return cached layout data from a previous run.
	// Do not make security decisions based on this value alone.
	if (!user) {
		redirect(303, '/login')
	}

	// ...
}
// Preferred: use locals.user for security decisions.
// Use parent() data only for values the layout computed that are
// more complex than what locals contains (enriched permissions, etc.).

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

	// parent() can be used for enriched data from the layout, not for
	// the auth check itself.
	const { tenantConfig } = await parent()

	const response = await fetch(`/api/${tenantConfig.apiPrefix}/posts`)
	return { posts: response.ok ? await response.json() : [] }
}

Redirecting instead of returning a 403 for authorization failures

// Avoid: sending an authenticated user to /login when they lack permission.
// The user IS logged in. Sending them to login implies their session is broken.
// They will log in successfully and be redirected back to the same forbidden page.

export const load: PageServerLoad = async ({ locals, url }) => {
	if (!locals.user || locals.user.role !== 'admin') {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}
	// ...
}
// Preferred: separate the authentication check from the authorization check.
// Unauthenticated users go to /login. Authenticated non-admins get a 403.

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

	if (locals.user.role !== 'admin') {
		error(403, 'This page requires administrator access.')
	}

	// ...
}

The requireRole() utility encodes this pattern automatically. Using it means the check is never written incorrectly by hand.

Putting session validation inside load functions instead of the hook

// Avoid: each load function that needs auth makes its own session call.
// A three-level route hierarchy makes up to three validation calls per request.

// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
	const sessionId = cookies.get('sid')
	const response = await fetch('/api/session/validate', {
		/* ... */
	})
	const user = response.ok ? (await response.json()).user : null
	return { user }
}

// src/routes/app/+layout.server.ts
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
	// Validates the same session again. Same network call. Same result.
	const sessionId = cookies.get('sid')
	const response = await fetch('/api/session/validate', {
		/* ... */
	})
	const user = response.ok ? (await response.json()).user : null
	if (!user) redirect(303, '/login')
	return { user }
}
// Preferred: validate once in the hook, read from locals in every load function.
// Zero redundant session calls. locals.user is current for the entire request.

// hooks.server.ts validates once.
// Load functions read locals.user.

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

Performance and Scaling Considerations

Session validation is typically the most expensive operation in the auth stack: a network call to a database or session store, a token verification, a cache lookup. Doing it once in the hook and passing the result through locals keeps the cost flat regardless of route complexity. An application with a ten-level layout hierarchy still makes one session validation call per request.

The requireLogin() utility reads from locals without making any additional network calls. It is a synchronous memory read plus an optional redirect(). The cost is negligible.

For high-traffic applications, the session validation call in the hook can become a bottleneck. Common mitigations include using a signed, self-contained token (JWT with a short expiry) that can be verified cryptographically without a network call, and caching validated sessions in memory or a fast store like Redis with a short TTL. The hook implementation stays the same: extract the identifier, validate it, attach the result to locals. The implementation of the validation changes from a database call to a cryptographic verification or cache lookup.

The layout guard serves as an early exit for the entire subtree of load functions. If +layout.server.ts redirects an unauthenticated user, none of the page load functions in that section run. This is the performance benefit of layout-level guards that page-level guards alone do not provide: for initial hard navigations, the layout guard prevents downstream load functions from running at all. For client-side navigations where the layout is skipped, the page load guard catches the failure at the page level.

For routes with multiple layers of permission checks (authenticated AND editor AND tenant-validated), consider building a composed utility rather than chaining separate calls. Each requireRole() call is cheap, but composing them into a single utility improves readability and makes the full permission requirement explicit at a glance:

// src/lib/server/auth.ts — extended utilities

export function requireEditorAccess() {
	const user = requireLogin()

	if (user.role !== 'editor' && user.role !== 'admin') {
		error(403, 'Editor access required.')
	}

	return user
}

export function requireAdminOrSelf(targetUserId: string) {
	const user = requireLogin()

	if (user.id !== targetUserId && user.role !== 'admin') {
		error(403, 'You can only access your own data.')
	}

	return user
}

These composites document the access requirements at the utility level rather than duplicating logic across load functions. When access requirements change, you update one place.


What’s Next

This is the final article in the SvelteKit Data Loading series. The series has covered the full lifecycle of server data in a SvelteKit application: how load functions are structured, how data flows through the layout hierarchy, how requests and responses are controlled, how load functions decide when to rerun, and how authentication integrates with every layer of the stack.


See Also