Protecting Routes
Authentication means knowing who the user is. Authorization means controlling what they can access. In SvelteKit, you protect routes by checking authentication in hooks or load functions and redirecting unauthenticated users. The pattern is straightforward: set user info in event.locals during the handle hook, then check it wherever access control is needed.
Setting User Info in Hooks
The foundation is the handle hook from hooks.server.ts. It runs on every request and populates event.locals:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifySession } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get('session');
if (sessionToken) {
const user = await verifySession(sessionToken);
event.locals.user = user; // null if session is invalid
} else {
event.locals.user = null;
}
return resolve(event);
};
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
} | null;
}
}
}
export {};
Now every load function and action has access to event.locals.user. The type system knows whether the user is authenticated.
Redirecting in the Handle Hook
The most aggressive protection: block unauthenticated users from entire route groups in the hook itself:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { verifySession } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get('session');
if (sessionToken) {
event.locals.user = await verifySession(sessionToken);
} else {
event.locals.user = null;
}
// Protect all /app/* routes
if (event.url.pathname.startsWith('/app') && !event.locals.user) {
const returnTo = encodeURIComponent(event.url.pathname);
redirect(303, `/login?returnTo=${returnTo}`);
}
// Protect all /admin/* routes — require admin role
if (event.url.pathname.startsWith('/admin')) {
if (!event.locals.user) {
redirect(303, '/login');
}
if (event.locals.user.role !== 'admin') {
redirect(303, '/app');
}
}
return resolve(event);
};
This approach is simple and centralized. Every protected route is defined in one place. The downside is that the hook grows as you add more route groups.
The Layout Server Guard Pattern
A cleaner approach for protecting route groups: use +layout.server.ts at the root of a protected section. Every child route automatically inherits the protection:
// src/routes/app/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
// Return user data for all child routes
return {
user: locals.user
};
};
Every page under /app/ is now protected. The layout load function runs before any page load function in that section. If the user is not authenticated, they are redirected before the page ever loads.
For admin routes, add another guard:
// src/routes/admin/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
if (locals.user.role !== 'admin') {
error(403, 'Admin access required');
}
return {
user: locals.user
};
};
This pattern scales well. Each protected section has its own layout guard with its own rules.
Page-Level Protection
Some pages need individual protection rules that differ from their parent layout:
// src/routes/app/settings/danger-zone/+page.server.ts
import type { PageServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
// Extra check: only account owners can access danger zone
if (!locals.user.isAccountOwner) {
error(403, 'Only the account owner can access this page');
}
return {
user: locals.user
};
};
Accessing User Data in Load Functions
Once the layout guard passes, child routes can access user data through the parent layout's data:
// src/routes/app/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return {
user: {
id: locals.user.id,
name: locals.user.name,
email: locals.user.email,
role: locals.user.role
}
};
};
// src/routes/app/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// locals.user is guaranteed to exist because the parent layout guard passed
const stats = await getDashboardStats(locals.user!.id);
return { stats };
};
<!-- src/routes/app/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Dashboard for {data.user.name}</h1>
<div class="stats">
<p>Active projects: {data.stats.projects}</p>
<p>Open tasks: {data.stats.tasks}</p>
</div>
The data.user comes from the parent layout's load function. SvelteKit merges parent and child data automatically.
Protecting Form Actions
Actions run server-side and should also check authentication:
// src/routes/app/settings/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { redirect, fail } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(303, '/login');
return { user: locals.user };
};
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
if (!locals.user) redirect(303, '/login');
const formData = await request.formData();
const name = formData.get('name') as string;
if (!name || name.length < 2) {
return fail(400, { name, error: 'Name must be at least 2 characters' });
}
await updateUser(locals.user.id, { name });
return { success: true };
},
deleteAccount: async ({ locals, cookies }) => {
if (!locals.user) redirect(303, '/login');
await deleteUser(locals.user.id);
cookies.delete('session', { path: '/' });
redirect(303, '/');
}
};
Always check auth in actions even if the page is protected. Actions can be called directly via POST requests without loading the page.
Client-Side Route Protection
Server-side guards handle the security. Client-side checks provide a better user experience by preventing UI flicker:
<!-- src/routes/app/+layout.svelte -->
<script lang="ts">
import type { LayoutData } from './$types';
import { goto } from '$app/navigation';
import { page } from '$app/state';
let { data, children }: { data: LayoutData; children: any } = $props();
// Defensive client-side check
$effect(() => {
if (!data.user) {
goto(`/login?returnTo=${encodeURIComponent(page.url.pathname)}`);
}
});
</script>
{#if data.user}
<nav>
<span>Logged in as {data.user.name}</span>
<a href="/app/settings">Settings</a>
<form method="POST" action="/logout">
<button>Logout</button>
</form>
</nav>
{@render children()}
{:else}
<p>Redirecting to login...</p>
{/if}
The {#if data.user} guard prevents rendering protected content during the brief moment before a redirect completes. This is a UX concern, not a security one. The server-side guard is what actually prevents unauthorized access.
Role-Based Access Control
For applications with multiple roles, create a helper to check permissions:
// src/lib/server/auth.ts
type Role = 'admin' | 'editor' | 'user';
const roleHierarchy: Record<Role, number> = {
admin: 3,
editor: 2,
user: 1
};
export function hasRole(user: App.Locals['user'], requiredRole: Role): boolean {
if (!user) return false;
return roleHierarchy[user.role] >= roleHierarchy[requiredRole];
}
// src/routes/admin/posts/+page.server.ts
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { hasRole } from '$lib/server/auth';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(303, '/login');
if (!hasRole(locals.user, 'editor')) error(403, 'Editor access required');
const posts = await getAllPosts();
return { posts };
};
Common Pitfalls
- Relying only on client-side guards. Client-side checks are a UX improvement, not security. A user can disable JavaScript or call your API directly. Always enforce auth on the server in hooks, load functions, or actions.
- Forgetting to protect actions. A protected page does not mean protected actions. Anyone can send a POST request to your action endpoint. Always check
locals.userin actions. - Leaking user data in universal load functions.
+page.ts(universal load) runs on both server and client. Data returned from it is visible in the browser. Use+page.server.tsfor sensitive data that should never reach the client. - Not preserving the return URL. When redirecting to login, capture the original URL so you can send the user back after authentication. Use a
returnToquery parameter. - Checking auth in every page individually. Use the layout guard pattern instead. One
+layout.server.tsat the root of a protected section covers all child routes automatically.
Key Takeaways
- Set user info in
event.localsduring the handle hook. Every load function and action can then access it. - The layout server guard pattern (
+layout.server.tswith a redirect) protects entire route groups with a single check. - Always check authentication in form actions, even if the page is protected. Actions can be called directly.
- Client-side guards prevent UI flicker but are not security measures. Server-side checks are the real protection.
- Use role-based helpers for applications with multiple permission levels.
- Capture the return URL when redirecting to login so users land back where they started after authenticating.