Skip to content

Authentication

Honeycomb uses Supabase Auth with server-side session management through SvelteKit hooks. All route protection is centralized in hooks.server.ts — individual pages and layouts should never contain auth redirect logic.

Browser request
|
v
hooks.server.ts
|-- 1. supabase handle -> creates server client, attaches safeGetSession
|-- 2. guard handle -> checks session, redirects based on route type
|
v
+layout.server.ts (root)
| calls safeGetSession(), passes session + user + cookies to client
|
v
+layout.ts (root)
| creates browser/server Supabase client
| passes supabase + session to all child routes
|
v
+page.server.ts / +layout.server.ts (no auth redirects here)
|
v
Svelte component renders

The two handles run in sequence via sequence(supabase, guard).

The first hook creates a Supabase server client and attaches it to event.locals:

src/hooks.server.ts
const supabase: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: "/" });
});
},
},
},
);
event.locals.safeGetSession = async () => {
const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) return { session: null, user: null };
const { data: { session } } = await event.locals.supabase.auth.getSession();
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name: string) {
return name === "content-range" || name === "x-supabase-api-version";
},
});
};

Key details:

  • @supabase/ssr is used (not the browser client) to handle cookie-based sessions correctly on the server.
  • safeGetSession calls getUser() first (server-side JWT verification) and only then getSession(). If the user check fails, it returns nulls rather than throwing.
  • Response header filtering allows content-range and x-supabase-api-version through serialization, which Supabase needs for paginated queries and version negotiation.

The helper is attached to event.locals and follows a two-step verification:

  1. getUser() — makes a server-side call to Supabase Auth to verify the JWT. This catches expired or tampered tokens.
  2. getSession() — only called if getUser() succeeds. Returns the session object (which includes the JWT needed for RLS).

If getUser() fails (expired token, invalid signature, network error), the helper returns { session: null, user: null } instead of throwing. This means all downstream code can safely check for null without error handling.

The root server layout calls safeGetSession() and passes session data and cookies to the client:

src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session, user } = await safeGetSession();
return {
session,
user,
cookies: cookies.getAll(),
};
};

The cookies are forwarded so the client-side layout can initialize a Supabase client with the same cookie state.

The universal layout creates a Supabase client appropriate for the current environment (browser or SSR) and exposes it to all child routes:

src/routes/+layout.ts
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth');
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
global: { fetch },
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
global: { fetch },
cookies: {
getAll() {
return data.cookies;
},
},
});
const { data: { session } } = await supabase.auth.getSession();
return { supabase, session };
};

Key points:

  • depends('supabase:auth') registers a dependency so calling invalidate('supabase:auth') anywhere will re-run this load function and refresh the session.
  • Browser vs server: On the client, createBrowserClient is used (cookies handled automatically by the browser). On the server during SSR, createServerClient is used with the forwarded cookies from +layout.server.ts.
  • getSession() is safe here: On the client it reads from memory, and on the server the session was already verified by safeGetSession in the server layout.

The (app) route group layout runs for all authenticated routes. It fetches the user’s profile, unread notification count, and installed apps:

// src/routes/(app)/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
const { user } = await locals.safeGetSession();
const { data: profile } = await locals.supabase
.from('profiles')
.select('id, username, first_name, last_name, avatar_url, verified, role, type')
.eq('id', user!.id)
.single();
const { data: unreadRows } = await locals.supabase
.from('notifications')
.select('id')
.eq('notifiable_id', user!.id)
.is('read_at', null);
const { data: installedApps } = await locals.supabase
.from('user_apps')
.select('app_id, is_active, apps(slug, name, icon)')
.eq('user_id', user!.id)
.eq('is_active', true);
return {
user,
profile,
unreadNotifications: unreadRows?.length ?? 0,
installedApps: /* mapped array */,
};
};

The Locals interface declares what hooks attach to every request:

src/app.d.ts
interface Locals {
supabase: SupabaseClient<Database>;
safeGetSession(): Promise<{
session: Session | null;
user: Session["user"] | null;
}>;
}
interface PageData {
session: Session | null;
user?: Session["user"] | null;
}

The guard handle in hooks.server.ts enforces all auth, role, and onboarding checks in a single place. Route classification is defined in src/lib/server/guards.ts.

CategoryExamplesBehavior
Public routes/auth/callback, /auth/confirm, /api/stripe/webhookNo guard at all — always accessible
Public auth routes/sign-in, /sign-up, /forgot-password, /reset-passwordAccessible to unauthenticated users; authenticated users are redirected to /feed
Protected routes/feed, /profile, /messages, /settings, /admin, etc.Require authentication; unauthenticated users are redirected to /sign-in
Admin routes/admin/*Require authentication AND role === "admin" on the user’s profile
Onboarding-gated routesAll protected routes except /onboarding, /sign-out, and /api/*Require a completed onboarding record
Landing page/Unauthenticated users see the marketing page; authenticated users redirect to /feed
// src/hooks.server.ts (guard handle, simplified)
const guard: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
// 1. Fully public routes -- skip everything
if (PUBLIC_ROUTES.some((r) => path.startsWith(r))) {
return resolve(event);
}
const { session, user } = await event.locals.safeGetSession();
// 2. No session
if (!session) {
if (isProtectedRoute(path)) throw redirect(302, "/sign-in");
return resolve(event); // landing page or auth pages
}
// 3. Has session -- redirect away from auth/landing pages
if (PUBLIC_AUTH_ROUTES.some((r) => path.startsWith(r))) throw redirect(302, "/feed");
if (isLandingPage(path)) throw redirect(302, "/feed");
// 4. Admin check
if (isAdminRoute(path)) {
const { data: profile } = await event.locals.supabase
.from("profiles").select("role").eq("id", user!.id).single();
if (profile?.role !== "admin") throw redirect(302, "/feed");
}
// 5. Onboarding check
if (requiresOnboarding(path)) {
const { data: onboard } = await event.locals.supabase
.from("onboards").select("completed").eq("user_id", user!.id).maybeSingle();
if (!onboard) throw redirect(302, "/onboarding");
}
return resolve(event);
};

All route-matching logic lives in src/lib/server/guards.ts:

export const PUBLIC_AUTH_ROUTES = ["/sign-in", "/sign-up", "/forgot-password", "/reset-password"];
export const PUBLIC_ROUTES = [
"/auth/callback", "/auth/confirm", "/auth/error",
"/auth/forgot-password", "/auth/reset-password", "/auth/resend-verification",
"/verify-email", "/api/stripe/webhook",
];
export const AUTHENTICATED_REDIRECT = "/feed";
export const UNAUTHENTICATED_REDIRECT = "/sign-in";
export function isProtectedRoute(path: string): boolean { /* ... */ }
export function isAdminRoute(path: string): boolean { /* ... */ }
export function requiresOnboarding(path: string): boolean { /* ... */ }
export function isLandingPage(path: string): boolean { /* ... */ }

isProtectedRoute checks path.startsWith() against a list of known prefixes: /feed, /post/, /profile, /explore, /messages, /notifications, /bookmarks, /settings, /wallet, /marketplace, /jobs, /business, /admin, /store/, /drafts, /onboarding, and any /api/ route not in PUBLIC_ROUTES.

User submits login form
| POST ?/login
v
+page.server.ts (login action)
| 1. Validate with signInSchema
| 2. supabase.auth.signInWithPassword()
| 3. redirect(303, "/")
v
hooks.server.ts guard
| session exists + landing page -> redirect to /feed

The login form sends email and password via a native form POST to the ?/login action. On success, Supabase sets session cookies and the user is redirected.

User submits signup form
| POST ?/signup
v
+page.server.ts (signup action)
| 1. Validate with signUpSchema
| 2. Check username uniqueness against profiles table
| 3. supabase.auth.signUp() with user metadata
| 4. redirect(303, "/verify-email?email=...")
v
/verify-email page
| Displays "check your inbox" message
| User clicks link in email
v
/auth/confirm endpoint
| Verifies OTP token_hash
| redirect(303, "/") (or "/reset-password" for recovery type)

User metadata passed during signup:

options: {
emailRedirectTo: `${event.url.origin}/auth/confirm`,
data: {
first_name: parsed.data.first_name,
last_name: parsed.data.last_name,
username: parsed.data.username,
},
},

This metadata is available immediately in the auth.users record and can be used by database triggers (e.g., to create a profiles row on signup).

// POST ?/google on /sign-in
const { data, error } = await event.locals.supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${event.url.origin}/auth/callback`,
},
});
if (data.url) throw redirect(303, data.url);

The OAuth flow:

  1. Server initiates OAuth with Supabase, which returns a Google authorization URL.
  2. User is redirected to Google to authenticate.
  3. Google redirects back to /auth/callback with an authorization code.
  4. The callback endpoint exchanges the code for a session.
// src/routes/(auth)/auth/callback/+server.ts
export const GET: RequestHandler = async (event) => {
const code = url.searchParams.get("code");
const next = safeInternalRedirectPath(url, url.searchParams.get("next"), "/");
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) throw redirect(303, next);
}
throw redirect(303, "/auth/error");
};

The safeInternalRedirectPath utility prevents open redirect attacks by validating that the next parameter is a relative path on the same origin. It rejects absolute URLs, scheme-relative URLs (//...), and backslash-containing paths.

After sign-up, the user is redirected to /verify-email?email=.... This page:

  1. Displays the email address and a “check your inbox” message.
  2. Provides a “Resend verification email” button that calls /auth/resend-verification via fetch.
  3. Tracks resend state with reactive $state variables (resending, resent).

The verification email links to /auth/confirm which verifies the OTP:

// src/routes/(auth)/auth/confirm/+server.ts
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
const token_hash = url.searchParams.get("token_hash");
const type = url.searchParams.get("type") as EmailOtpType | null;
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({ type, token_hash });
if (!error) {
let location = safeInternalRedirectPath(url, url.searchParams.get("next"), "/");
if (type === "recovery") location = "/reset-password";
throw redirect(303, location);
}
}
throw redirect(303, "/auth/error");
};

The confirm endpoint handles both email verification and password recovery OTPs. When type === "recovery", it redirects to /reset-password instead of the default destination.

The verify-email page calls /auth/resend-verification via client-side fetch:

const res = await fetch("/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: data.email }),
});

Rate limits apply: maximum 3 attempts per IP + email combination within a 15-minute window, with a minimum 2-minute cooldown between individual requests.

User submits email on /forgot-password
| fetch POST /auth/forgot-password
v
+server.ts
| 1. CSRF check
| 2. Validate with forgotPasswordSchema
| 3. supabase.auth.resetPasswordForEmail(email, { redirectTo })
v
User receives email, clicks link
|
v
/auth/confirm (type=recovery)
| Verifies OTP, redirects to /reset-password
v
/reset-password page
| fetch POST /auth/reset-password
v
+server.ts
| 1. CSRF check
| 2. Rate limit check (5 attempts / 15 min)
| 3. Validate with resetPasswordSchema
| 4. supabase.auth.updateUser({ password })

The forgot-password and reset-password endpoints use +server.ts (not form actions) because they are called via fetch from client-side components. Both apply CSRF protection via csrfProtection(event).

After authentication, users must complete onboarding before accessing the app. The guard checks for a record in the onboards table:

if (requiresOnboarding(path)) {
const { data: onboard } = await event.locals.supabase
.from("onboards")
.select("completed")
.eq("user_id", user!.id)
.maybeSingle();
if (!onboard) throw redirect(302, "/onboarding");
}

Routes excluded from the onboarding check:

  • /onboarding itself (to prevent redirect loops)
  • /sign-out
  • All /api/* routes

The onboarding page at /onboarding:

  1. Loads the user’s current profile and a list of suggested users to follow.
  2. updateProfile action — lets the user set their username, bio, caption, website, and upload an avatar to Supabase Storage.
  3. complete action — upserts an onboards record with completed: true and redirects to /feed.
// Marking onboarding complete
await event.locals.supabase
.from("onboards")
.upsert(
{ user_id: user.id, completed: true, step: 3 },
{ onConflict: "user_id" }
);
redirect(303, "/feed");

Supabase Row Level Security (RLS) policies use the JWT from the user’s session to determine access. The relationship works as follows:

  1. Login — Supabase Auth issues a JWT containing the user’s id (as auth.uid()) and any custom claims.
  2. Server requests — When locals.supabase makes a database query, the @supabase/ssr client automatically attaches the session JWT to the request.
  3. RLS evaluation — Postgres evaluates RLS policies using auth.uid() (extracted from the JWT) to filter rows. For example, a policy like user_id = auth.uid() ensures users only see their own data.
  4. Client requests — The browser Supabase client (created in +layout.ts) also carries the session JWT, so client-side queries respect the same RLS policies.
Browser Supabase client (from +layout.ts)
| JWT in Authorization header
v
Supabase PostgREST
| SET request.jwt.claims = '...'
v
Postgres RLS policies
| auth.uid() = JWT sub claim
v
Filtered rows returned

Because both the server and browser clients use the anon key with the user’s JWT, the security model is consistent: the same RLS policies apply regardless of where the query originates. The safeGetSession helper ensures the JWT is verified server-side before any database queries run in layouts or page loads.

Once past the hooks, pages and layouts can access the session through event.locals:

// In any +page.server.ts or +layout.server.ts
export const load: PageServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
return { session, user };
};

The Supabase client is also available at locals.supabase for database queries:

const { data: profile } = await locals.supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
RouteFilePurpose
/sign-insrc/routes/(auth)/sign-in/+page.server.tsEmail/password login and Google OAuth actions
/sign-upsrc/routes/(auth)/sign-up/+page.server.tsAccount creation and Google OAuth actions
/verify-emailsrc/routes/(auth)/verify-email/+page.sveltePost-signup email verification holding page
/forgot-passwordsrc/routes/(auth)/forgot-password/+page.svelteClient-only page that calls the API endpoint
/reset-passwordsrc/routes/(auth)/reset-password/+page.svelteClient-only page that calls the API endpoint
/auth/callbacksrc/routes/(auth)/auth/callback/+server.tsOAuth code-to-session exchange
/auth/confirmsrc/routes/(auth)/auth/confirm/+server.tsEmail OTP verification (signup + recovery)
/auth/forgot-passwordsrc/routes/(auth)/auth/forgot-password/+server.tsAPI: send password reset email
/auth/reset-passwordsrc/routes/(auth)/auth/reset-password/+server.tsAPI: update password (rate-limited)
/auth/resend-verificationsrc/routes/(auth)/auth/resend-verification/+server.tsAPI: resend signup verification email
/auth/errorsrc/routes/(auth)/auth/error/+page.svelteGeneric auth error page
/onboardingsrc/routes/(onboarding)/onboarding/+page.server.tsProfile setup and onboarding completion

The safeInternalRedirectPath utility (from src/lib/server/safe-redirect.ts) is used in the callback and confirm endpoints to validate next parameters:

export function safeInternalRedirectPath(
baseUrl: URL,
next: string | null | undefined,
fallback = "/",
): string {
// Rejects: null, empty, non-slash-prefixed, "//...", backslash paths
// Verifies resolved origin matches baseUrl origin
// Returns pathname + search, or the fallback
}

All +server.ts auth endpoints call csrfProtection(event) before processing. See Forms & Validation for details.

Password reset and verification resend endpoints implement per-IP rate limiting to prevent abuse. See Forms & Validation for the specific limits.

Supabase session cookies are set with path: "/" to ensure they are available across all routes. The @supabase/ssr library handles httpOnly, secure, and sameSite flags automatically based on the environment.

  1. Add a path.startsWith("/your-route") check to isProtectedRoute() in src/lib/server/guards.ts.
  2. The onboarding gate automatically applies. If your route should bypass onboarding, add an exclusion in requiresOnboarding().
  3. If the route requires admin access, add a check in isAdminRoute().
  4. No auth logic is needed in the route’s +page.server.ts — the hook handles it.