Introduction
If you've deployed a SvelteKit application to production, you've likely discovered that client-side route protection isn't protection at all—anyone with browser dev tools can bypass it. Protecting routes in SvelteKit: best practices explained means understanding the layered approach that keeps sensitive pages and API endpoints genuinely private. We'll walk through server-side validation, proper redirect patterns, and the separation between page routes and API routes that makes the difference between a secure application and one that just looks secure.
Check locals.user in +layout.server.ts
The foundation of route protection in SvelteKit is the +layout.server.ts file. This runs exclusively on the server and executes before any component renders, making it the ideal place to verify user identity. When a request enters your application, the hooks system populates locals with authentication data—typically after parsing a session cookie or JWT token.
Consider a dashboard that should only be visible to authenticated users. Here's how you'd structure this protection:
// src/routes/dashboard/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return {
user: locals.user
};
};This check runs server-side before the layout component mounts. The locals.user object typically comes from your hooks.server.ts, where you've parsed authentication credentials. If authentication fails, execution stops immediately and the redirect fires. The user never receives any HTML from the protected route—only a 303 response that the browser follows to your login page.
For multi-level protection—say you have a dashboard, and within it an admin section—you stack the checks:
// src/routes/dashboard/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return { user: locals.user };
};
// src/routes/dashboard/admin/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user?.isAdmin) {
redirect(303, '/dashboard');
}
return { user: locals.user };
};Each layout protects its own subtree. A non-admin user hitting /dashboard/admin gets redirected back to the general dashboard. This is cleaner than one monolithic guard and allows different levels of access at different route depths.
Using redirect(303, '/login')
SvelteKit's redirect function is the proper way to respond when protection fails. It's an exception-like construct that halts all further execution and immediately sends a redirect response. The status code matters: 303 See Other is the safe choice for redirects after form submissions or failed authorization checks because it guarantees the browser will issue a GET request to the new URL, never a POST.
Here's why this matters in a real scenario: imagine a user's session expires mid-workflow. They try to submit a form on the protected page. Your form action receives the request and checks locals.user:
// src/routes/dashboard/orders/+page.server.ts
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
const formData = await request.formData();
// Process order...
}
};When redirect() is thrown, SvelteKit catches it and sends a 303 response to the browser. The browser then performs a fresh GET to /login, ensuring the POST data doesn't get sent to the login page (which would be nonsensical). This is safety-critical in e-commerce and financial applications where accidental form resubmission can cause duplicate transactions.
If you need a different status code—say, a permanent redirect—use redirect(301, '/new-url'), but for protection failures, stick with 303.
Protecting API routes separately
API routes live at src/routes/api/ and behave differently from pages. They don't render HTML; they return data. A user might bypass your page protections if you only guard the page layer but leave the API unprotected. We've seen production incidents where /api/user-profile was publicly readable while /dashboard showed a login page.
Protect each API route's +server.ts file independently:
// src/routes/api/user-profile/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
return error(401, 'Unauthorized');
}
return json({ user: locals.user });
};For a more complex example with role-based access, fetch sensitive data only after validation:
// src/routes/api/admin/reports/+server.ts
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/database';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user?.isAdmin) {
return error(403, 'Forbidden');
}
const reports = await db.reports.findMany();
return json({ reports });
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user?.isAdmin) {
return error(403, 'Forbidden');
}
const payload = await request.json();
const report = await db.reports.create(payload);
return json({ report }, { status: 201 });
};Note the difference between 401 Unauthorized (user not logged in) and 403 Forbidden (user logged in but lacks permission). This distinction helps client-side code decide whether to redirect to login or show an access-denied message.
A common pattern in production applications: protect at multiple levels. The page layout checks auth, the page's server load checks auth, and the API endpoints check auth again. Redundancy here is a feature—it ensures that if one layer has a bug, the others catch it.
Never rely on client-side checks
Client-side route guards (using Svelte stores or conditional rendering) are useful for user experience, but they're not security. We can illustrate this with a concrete example: suppose you hide the admin link when isAdmin is false in a store.
// This is NOT secure protection
// src/routes/dashboard/+page.svelte
<script>
import { user } from '$lib/stores';
</script>
{#if $user?.isAdmin}
<a href="/dashboard/admin">Admin Panel</a>
{/if}An attacker can open the browser console and navigate directly to /dashboard/admin. Even better, they can open dev tools, inspect the network tab, and call your APIs directly. The store is a client-side construct; it exists only in the browser and can be modified or circumvented in seconds.
Client-side checks are valuable for UX: they prevent accidental navigation and provide immediate feedback. But they must be paired with server-side validation. The server must always verify permissions before responding with data or allowing state changes. Think of client-side checks as "be nice to the user" and server-side checks as "actually enforce the rules."
Common mistakes
Forgetting to protect API routes: Protecting page layout doesn't protect your API. A user can see a login page but still fetch data via fetch('/api/sensitive-data') from the browser console if the API isn't guarded.
Using 302 instead of 303: A 302 Found redirect preserves the original HTTP method, so a POST to a protected endpoint might resubmit as POST after redirect. Always use 303 See Other for protection redirects to force GET.
Checking locals in +page.ts instead of +layout.server.ts: Page-level loads run after the layout, so unprotected layout code might execute first. Put protection in layout; it runs earlier in the hierarchy.
Assuming session cookies are tamper-proof: Store session data server-side and use cookies only as session IDs. Never put user roles or permissions in the cookie itself without cryptographic signing and verification.
Protecting routes without protecting data access: You might protect the page, but if your database queries don't filter by user ID, an attacker who finds the right API call could read other users' data. Always filter queries by the authenticated user.
Key takeaways
- Protect routes server-side using
locals.userchecks in+layout.server.ts; client-side checks are not security. - Use
redirect(303, '/login')to stop execution and force a GET to the login page when authorization fails. - Protect API routes independently in
+server.tsfiles; assume users will call APIs directly. - Stack protection checks across route hierarchy: layout checks for auth, nested layouts check for roles, API endpoints verify again.
- Return
401for unauthenticated requests and403for authenticated users lacking permission. - Filter database queries by authenticated user ID to prevent data leakage even if route checks pass.






Comments
Loading comments…