The guard
All route protection lives in one place — hooks.server.ts:
const authGuard: Handle = async ({ event, resolve }) => {
const { session, user } = await event.locals.safeGetSession();
event.locals.session = session;
event.locals.user = user;
if (!session && pathname.startsWith('/app')) {
redirect(303, '/auth/login');
}
const authExempt = ['/auth/logout', '/auth/callback', '/auth/update-password'];
if (session && pathname.startsWith('/auth') && !authExempt.some((p) => pathname.startsWith(p))) {
redirect(303, '/app/dashboard');
}
return resolve(event);
}; Adding a protected area
To protect a new top-level area (say /admin), add one rule:
if (!session && pathname.startsWith('/admin')) {
redirect(303, '/auth/login');
} Because the guard runs on every request, protection cannot be forgotten on a new child route — the prefix covers the whole tree.
Role-based access
locals.user carries app_metadata from Supabase. For an admin area, check a
custom claim:
if (pathname.startsWith('/admin')) {
if (!session || user.app_metadata.role !== 'admin') {
redirect(303, '/auth/login');
}
} Set the claim with Supabase’s auth.admin.updateUserById from trusted server
code — never from client input.
API endpoints
/api/* is not covered by the redirect guard (redirecting an API call is
useless). Endpoints enforce auth explicitly:
export const POST: RequestHandler = async ({ locals }) => {
const user = locals.user;
if (!user) redirect(303, '/auth/login'); // or return json(..., { status: 401 })
// …
}; /api/stripe/checkout and /api/stripe/portal do exactly this — see Checkout & Portal.