SvelteKit hooks and middleware
SvelteKit doesn't have a "middleware" concept in the Express sense — instead, it has hooks.server.ts, a single file that runs on every server-side request before your routes see it. This is where authentication and session validation happen. In the shipped starter, the hook is deliberately small: it extracts the session cookie, resolves the user, and attaches them to event.locals. Getting the shape of this file right is the difference between a clean SaaS architecture and a pile of duplicated logic scattered across every load function. This page shows the exact shipped hook, then walks through general SvelteKit extension patterns (route protection, org resolution, error handling) that you would add on top.
The handle function — the shipped starter's hook
The hook file exports a handle function (a Handle) that receives a RequestEvent and must return a Response. Every incoming request — pages, actions, API routes — passes through it. This is the entire shipped hook from src/hooks.server.ts:
import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { getUserBySessionToken, SESSION_COOKIE } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.sessionToken = event.cookies.get(SESSION_COOKIE);
const db = getDb();
event.locals.user = await getUserBySessionToken(db, event.locals.sessionToken);
return resolve(event);
};Three things stand out. First, it reads the session cookie and stores the raw token on event.locals.sessionToken — the token is never stored in the database, only its SHA-256 hash. Second, it resolves the user (a DB lookup joining sessions to users, via the shared getUserBySessionToken helper in $lib/server/auth) and attaches them to event.locals.user. Third, the hook does not redirect, does not resolve or attach an active organization, and does not wrap resolve in try/catch — route protection and error handling live in the route layer (the app layout redirects to login when there's no user; the org layout returns 404 when the caller isn't a member).
The resolve function is not optional — you must call it and return its result. If you forget the return, or call resolve twice, the request pipeline breaks silently. There is no compile-time error; the page just won't render.
event.locals: the shared context
event.locals is a plain object that you mutate inside handle. Every load function, form action, and endpoint that runs for that request can read from it. This is how the shipped starter attaches the authenticated user once, at the top, so every downstream load reads it. The type lives in src/app.d.ts:
declare global {
namespace App {
interface Locals {
sessionToken: string | undefined;
user: { id: string; email: string; name: string } | null;
}
}
}The type safety of locals is up to you. SvelteKit's App.Locals interface (in src/app.d.ts) defines what's available. If you skip this, every load function that reads event.locals.user will cast through any — technically functional, practically a bug magnet. Note there is no org field on Locals in the shipped kit — org resolution is per-route, covered below.
Cookies and session extraction — how the shipped hook does it
The session token arrives as a cookie on every request. event.cookies gives you read access. In the shipped kit the extraction is delegated to a single helper, getUserBySessionToken, defined in src/lib/server/auth.ts, which hashes the token, looks up the session row, checks expiry, joins to the user, and returns a { id, email, name } object (or null). The hook just wires cookie → helper → locals:
import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { getUserBySessionToken, SESSION_COOKIE } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.sessionToken = event.cookies.get(SESSION_COOKIE);
const db = getDb();
event.locals.user = await getUserBySessionToken(db, event.locals.sessionToken);
return resolve(event);
};The raw token is never stored in the database — only its SHA-256 hash (the sessions.id column is literally sha256(token)). The cookie itself is HttpOnly, SameSite=Lax, and Secure in production, and carries a 30-day expiry. This is covered in depth in the hashed session tokens guide.
Redirects: when and where — Not in the starter — recommended extension
SvelteKit's redirect function throws inside handle to send the user elsewhere. The shipped starter does not redirect from the hook — route gating happens in the app layout, which throws redirect(302, '/login') when there's no user. The following is a general pattern you can add if you prefer a single enforcement point in the hook. The key question is when to redirect from the hook vs. when to let the route itself decide:
- Redirect from the hook: When a route is fundamentally auth-gated and you want a single enforcement point — e.g., every request to
/dashboard/*without a valid session goes to/login. The hook checks, redirects, and the route never loads. - Redirect from the route: When a page is publicly accessible but shows different content for logged-in users — a marketing page that shows "Dashboard" in the nav when authenticated. The hook sets
event.locals.user; the route's load function decides what to render.
import { redirect } from '@sveltejs/kit';
// in handle():
const isProtected = event.url.pathname.startsWith('/dashboard');
if (isProtected && !event.locals.user) {
throw redirect(303, '/login');
}The 303 status code is correct for redirects after POST requests — it forces a GET. A 301 or 302 may cause the browser to replay the POST to the new URL, which is rarely what you want.
Error handling in hooks — Not in the starter — recommended extension
The shipped starter's hook has no error handling of its own — it lets errors bubble to SvelteKit, and the kit's error story lives in the route layer (a shared errorToFail mapper in src/lib/server/http.ts, covered in the error handling guide). If you want the hook itself to catch and shape errors, you can add a general pattern like this:
export const handle: Handle = async ({ event, resolve }) => {
try {
// ... session validation, user lookup, etc.
return await resolve(event);
} catch (err) {
console.error('Hook error:', err);
throw error(500, 'Internal server error');
}
};SvelteKit also exposes a handleError hook — this runs when any error occurs (in hooks, in load functions, in actions). It receives the error and the event, and should return a safe object. Use it as a catch-all logging layer:
export const handleError: HandleError = ({ error, event }) => {
console.error('Unhandled error:', error, event.url.pathname);
return {
message: 'Something went wrong.',
};
};The return value is what the client sees — never return the original error message unless it's explicitly user-facing. Database errors, file system errors, and authentication internals should never reach the browser.
Multi-tenant org resolution — Not in the starter — example pattern
The shipped starter resolves the active organization per-route, not in the hook: each org layout load calls getOrgForUser(db, userId, orgId) (in src/lib/server/services/orgs.ts), which returns the org plus the caller's role, or null when they aren't a member (the layout then throws a 404). There is no getActiveOrg function and no org field on locals in the kit.
If you'd rather attach an active org in the hook (a single code path instead of per-route checks), the general example pattern below shows how — note it joins memberships on userId and orgId (there is no orgSlug column on memberships in the kit's schema):
// EXAMPLE — general pattern, not shipped in the starter.
// in handle(), after user lookup, when orgId is known (e.g. from a param or cookie):
if (orgId && event.locals.user) {
const membership = await db.query.memberships.findFirst({
where: and(
eq(memberships.userId, event.locals.user.id),
eq(memberships.orgId, orgId),
),
});
if (membership) {
event.locals.org = { id: membership.orgId, role: membership.role };
}
}Every downstream load function reads event.locals.org and knows it's already validated. Note that the shipped kit deliberately keeps org resolution out of the hook and in the route layer, where the org id comes from the URL param. We cover the shipped per-route approach in detail in the multi-tenant auth guide.
Handle vs. sequence: when to use multiple hooks — Not in the starter — recommended extension
SvelteKit also exports sequence, which composes multiple handle functions into a pipeline. This is useful when you want to separate concerns — one hook for logging, one for auth, one for rate limiting metadata — without one massive function. The shipped starter uses a single handle and does not use sequence. If you refactor into separate hook functions, compose them like this (a general pattern — the placeholder modules are your own):
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
// General example — compose your own hook functions.
function authHandle(handleInput) { /* ... resolves user, returns resolve(event) */ }
function loggingHandle(handleInput) { /* ... logs, returns resolve(event) */ }
export const handle = sequence(loggingHandle, authHandle);Each function in the sequence receives the same event and resolve. The first function to not call resolve breaks the chain. In practice, most small-to-medium SaaS apps don't need sequence — a single well-structured handle function is easier to read and debug. Use sequence when your hook file exceeds 100 lines or when you have distinct teams owning different middleware layers.
Common mistakes
Mutating locals in load functions
Load functions should read from event.locals, not write to it. If two load functions on the same page both try to set locals.user, you get a race condition with nondeterministic results. The hook is the only place that should write to locals.
Forgetting to call resolve
If your handle function doesn't call resolve(event) and return its result, the request hangs or returns an empty response. This is a common bug when adding error handling — the catch block returns a 500 but forgets that the happy path needs resolve too.
Redirecting on every unauthenticated request
Some routes are public and show different content for logged-in users. If your hook redirects all unauthenticated requests to login, you break public pages, API endpoints, and login/signup forms themselves. Only redirect on routes that genuinely require auth.
Running expensive queries in the hook
The hook runs on every request. A slow database query here adds latency to every page load. Keep hook logic fast: indexed lookups only, no joins that touch millions of rows, no external API calls. If you need heavier context resolution, do it in the load function for routes that need it.
Production checklist
- Extract and validate the session token on every request — never skip this step, even on public routes, because you may need to differentiate "not logged in" from "expired session."
- Attach user context to event.locals in one place, not scattered across every load function — the shipped starter sets event.locals.user in its single handle pass.
- The handle function must call resolve(event) and return its result — omitting the return or calling resolve twice breaks the entire request pipeline silently.
- Use redirects sparingly inside hooks: only for auth-gated routes, not for every unauthenticated request. Public pages that show different content for logged-in users should handle that in their own load functions.
- For multi-tenant apps, resolve the active org per-route (a load function or layout), not in the hook — the shipped starter does not attach an org to locals. Join memberships on org_id + user_id.
- Pass structured data through locals, not loose string keys: event.locals.user is typed in src/app.d.ts of the starter.
- Handle errors with handleError or in the route layer, not inside handle() — the shipped starter's hook has no try/catch. Never leak stack traces or SQL errors to the client.
- The order of hook logic matters: parse the session, then resolve the user, then call resolve. Each step depends on the previous one.
- For rate limiting, the hook is the wrong place for the limiter itself — the starter rate-limits auth actions directly in the login/signup route, not in hooks.
- Test hooks with SvelteKit's test utilities: fake the RequestEvent, assert on locals after handle(), and verify that unauthorized requests redirect rather than render.
Related guides: Multi-tenant auth architecture · Hashed session tokens · Session management
Related reading
- Multi-tenant Authentication — application-layer tenancy patterns
- Session Management — server-side session patterns
- Rate Limiting Login — brute-force protection
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.