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.

Supabase RLS off on tables

Same as any Supabase app.

Read the guide →

$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 };
}