Authentication in SvelteKit: what developers should know
Building secure authentication feels deceptively simple until you hit production and realize session management, CSRF protection, and route access control are intricate moving parts. Authentication in SvelteKit: what developers should know covers the practical fundamentals that separate hobby projects from systems handling real user data. We'll walk through session cookies, load functions, protected routes, and the libraries that make this work so you can ship with confidence.
Why SvelteKit's authentication story matters
SvelteKit gives us server hooks and load functions that run on the server before pages render. That's our advantage. Unlike client-side frameworks where every auth check is a race condition, SvelteKit lets us validate sessions server-side before the browser sees anything. The tradeoff is that we need to think about where validation happens and how we manage state across the request lifecycle.
Most production incidents we've seen come from developers either trusting client-side checks alone or leaving auth logic scattered across multiple files with no single source of truth. The framework provides the tools; we just need to wire them correctly.
Session cookies — httpOnly, secure cookies set in actions
A session cookie is the foundation. When a user logs in, we create a session ID, store it server-side (or sign it), and send it to the client via an httpOnly cookie. The browser automatically includes this cookie in every request, and because it's httpOnly, JavaScript can't access it. That prevents XSS attacks from stealing credentials.
Here's what setting a session cookie looks like in a login action:
import { fail } from '@sveltejs/kit';
import { lucia } from '$lib/auth';
export const actions = {
login: async (event) => {
const data = await event.request.formData();
const email = data.get('email');
const password = data.get('password');
// Verify credentials (bcrypt or similar)
const user = await db.users.findOne({ email });
if (!user || !await verifyPassword(password, user.passwordHash)) {
return fail(401, { message: 'Invalid credentials' });
}
// Create session
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
// Set httpOnly, secure cookie
event.cookies.set(
sessionCookie.name,
sessionCookie.value,
{
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30 // 30 days
}
);
return { success: true };
}
};
Key points here: httpOnly: true prevents JavaScript access. secure: true means the browser only sends it over HTTPS. sameSite: 'lax' protects against CSRF by default. If you're using a library like Lucia, it handles the cookie creation; if you're rolling your own, these settings are non-negotiable.
When logging out, we clear the cookie and invalidate the session server-side:
export const actions = {
logout: async (event) => {
if (event.locals.session) {
await lucia.invalidateSession(event.locals.session.id);
}
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 0 // Expire immediately
});
return { success: true };
}
};
Load user in hooks — Attach the user to locals
SvelteKit's hooks.server.ts runs on every request. This is where we validate the session cookie and attach the user to event.locals. By doing this once, we avoid repeating the lookup in every load function or endpoint.
import type { Handle } from '@sveltejs/kit';
import { lucia } from '$lib/auth';
export const handle: Handle = async (event) => {
const sessionId = event.cookies.get(lucia.sessionCookieName);
if (!sessionId) {
event.locals.user = null;
event.locals.session = null;
return event;
}
try {
const { session, user } = await lucia.validateSession(sessionId);
if (session) {
// Renew session if near expiry
event.cookies.set(lucia.createSessionCookie(session.id).name, session.id, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30
});
}
event.locals.session = session;
event.locals.user = user;
} catch (error) {
event.locals.user = null;
event.locals.session = null;
}
return event;
};
Now every load function and endpoint can access event.locals.user` and event.locals.session without re-querying the database. This is also where we'd refresh session expiry if needed, extending the cookie's life as long as the user is active.
Protect routes — Redirect in layout server loads
Protected routes are typically enforced in layout server loads. For example, if /dashboard requires authentication, put the check in src/routes/dashboard/+layout.server.ts:
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
if (!event.locals.user) {
throw redirect(302, '/login');
}
return {
user: event.locals.user
};
};
Because this runs on the server before any page renders, the redirect happens before the client ever sees the protected component. If a user lands on /dashboard without a session, they're sent to /login. If they're logged in, the user object is available to the layout and all child routes via data.user.
For role-based access, extend this logic:
export const load: LayoutServerLoad = async (event) => {
if (!event.locals.user) {
throw redirect(302, '/login');
}
if (event.locals.user.role !== 'admin') {
throw error(403, 'Access denied');
}
return { user: event.locals.user };
};
Returning a 403 error page instead of redirecting signals that the user is authenticated but not authorized. Different UX, same security principle: the check happens server-side before rendering.
Libraries — Lucia-style guides or Auth.js for OAuth
Lucia is a lightweight, session-based auth library purpose-built for SvelteKit and other frameworks. It handles session creation, validation, and cookie management so you don't roll your own. The database adapter pattern lets you plug in any database.
Auth.js (formerly NextAuth) is heavier but handles OAuth integrations (Google, GitHub, etc.) and offers out-of-the-box user management. It's useful if you need multi-provider auth, but adds complexity if you only need email/password.
For most teams we work with, Lucia's approach is more straightforward: you own the user table, you control the flow, you integrate specific OAuth providers only as needed. Here's a minimal Lucia setup:
// src/lib/auth.ts
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '$lib/server/db';
export const lucia = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
path: '/'
}
}
});
declare global {
namespace Lucia {
type Auth = typeof lucia;
type DatabaseUserAttributes = {
email: string;
username: string;
};
}
}
Then in your login action, you call lucia.createSession() and let Lucia handle the rest. For OAuth, Auth.js has built-in providers; with Lucia, you write the OAuth exchange logic yourself but retain full control.
Common mistakes
Trusting client-side checks only. If your route protection lives in a +page.ts load function or client-side guard, an attacker can disable JavaScript or intercept the request. Always validate server-side in a +layout.server.ts or endpoint.
Storing sensitive data in cookies. User IDs and session IDs are fine; passwords, API keys, and PII are not. Keep cookies as minimal as possible.
Forgetting to set sameSite. Without it, CSRF attacks can forge requests. Always use sameSite: 'lax' at minimum.
Not invalidating sessions on logout. If you only delete the client cookie but leave the session active server-side, an attacker with the session ID can still impersonate the user. Always delete server-side and client-side.
Mixing auth logic across files. If some routes check event.locals.user and others call a separate auth service, you'll have inconsistencies. Centralize in hooks.server.ts and layout loads.
Key takeaways
- Session cookies are httpOnly, secure, and sameSite-protected. Set them in login actions, clear them on logout.
- Validate sessions once in
hooks.server.tsand attach the user toevent.localsfor access across your app. - Protect routes server-side in layout loads; redirect unauthenticated users before rendering protected pages.
- Use Lucia for session-based auth or Auth.js for OAuth; avoid rolling your own unless you have specific constraints.
- Never trust client-side auth checks. Always validate on the server before allowing access to protected resources.






Comments
Loading comments…