SvelteKit + Supabase security — +page.server.ts and form-actions
SvelteKit makes the security model visible at the file level. Any `+page.server.ts` contains a `load()` that runs on every render — your auth check is either the first line there or it is a bug. `+server.ts` files are raw API routes with no framework-level authentication. Form actions use SvelteKit's `form` directive and accept POST requests that need origin validation. The `hooks.server.ts` cross-cutting hook is where most teams wire auth refresh, session verification, and security headers. Getting this right means your load functions can assume `event.locals.session` is populated and valid; getting it wrong means every route file has to re-verify auth independently. Supabase RLS applies the same way as any Supabase integration. The SvelteKit-specific gotcha is that the Supabase client initialized in `hooks.server.ts` uses the user's session cookie and respects RLS; if you initialize a separate service-role client for admin operations, never expose it to a browser-reachable path.
What breaks on this stack
Missing auth in +page.server.ts load()
The load function runs on every render. Without `if (!event.locals.session) throw redirect(303, '/login')` at the top, the page data is returned to every visitor regardless of auth.
Form actions without origin check
Form actions accept cross-origin POSTs by default. Add an origin check in hooks.server.ts or inside each action's handler.
+server.ts endpoints without auth middleware
Raw `+server.ts` API routes receive requests directly. Either centralize auth in hooks.server.ts or repeat the check per endpoint — and be sure you catch GET, POST, PATCH, DELETE handlers.
$page.data leakage
Data returned from load() ends up in $page.data, accessible by any component and serialized to the client. Don't include sensitive server-only fields; filter before return.
Pre-ship checklist
- hooks.server.ts handles auth refresh + session verification for every request
- Every +page.server.ts load() has an auth assertion (or is documented-public)
- Every +server.ts handler has auth (GET, POST, PATCH, DELETE)
- Form actions verify origin on state-change operations
- Supabase RLS enabled on every table
- Service-role Supabase client isolated to server-only paths
- load() return values filtered — no raw row with service keys
- Security headers set in hooks.server.ts handle response
- CSRF protection enabled (SvelteKit has it; verify it's not disabled)
- Session cookie flags set (httpOnly, secure, sameSite)
Starter config
// hooks.server.ts — auth + Supabase client + CSP + CSRF-bypass guard
import type { Handle } from "@sveltejs/kit";
import { createServerClient } from "@supabase/ssr";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
process.env.PUBLIC_SUPABASE_URL!,
process.env.PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (k) => event.cookies.get(k),
set: (k, v, o) => event.cookies.set(k, v, { ...o, path: "/" }),
remove: (k, o) => event.cookies.delete(k, { ...o, path: "/" }),
},
}
);
const { data: { session } } = await event.locals.supabase.auth.getSession();
event.locals.session = session;
const response = await resolve(event);
response.headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return response;
};
// src/routes/(app)/orders/[id]/+page.server.ts — protected load
import { redirect } from "@sveltejs/kit";
export async function load({ locals, params }) {
if (!locals.session) throw redirect(303, "/login");
const { data } = await locals.supabase
.from("orders")
.select()
.eq("id", params.id)
.eq("user_id", locals.session.user.id)
.single();
return { order: data };
}