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.
How it works (high level)
Section titled “How it works (high level)”Browser request | vhooks.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) | vSvelte component rendersThe two handles run in sequence via sequence(supabase, guard).
Supabase server client
Section titled “Supabase server client”The first hook creates a Supabase server client and attaches it to event.locals:
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/ssris used (not the browser client) to handle cookie-based sessions correctly on the server.safeGetSessioncallsgetUser()first (server-side JWT verification) and only thengetSession(). If the user check fails, it returns nulls rather than throwing.- Response header filtering allows
content-rangeandx-supabase-api-versionthrough serialization, which Supabase needs for paginated queries and version negotiation.
The safeGetSession helper
Section titled “The safeGetSession helper”The helper is attached to event.locals and follows a two-step verification:
getUser()— makes a server-side call to Supabase Auth to verify the JWT. This catches expired or tampered tokens.getSession()— only called ifgetUser()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.
Session handling in layouts
Section titled “Session handling in layouts”Root +layout.server.ts
Section titled “Root +layout.server.ts”The root server layout calls safeGetSession() and passes session data and cookies to the client:
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.
Root +layout.ts (universal load)
Section titled “Root +layout.ts (universal load)”The universal layout creates a Supabase client appropriate for the current environment (browser or SSR) and exposes it to all child routes:
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 callinginvalidate('supabase:auth')anywhere will re-run this load function and refresh the session.- Browser vs server: On the client,
createBrowserClientis used (cookies handled automatically by the browser). On the server during SSR,createServerClientis 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 bysafeGetSessionin the server layout.
App layout ((app)/+layout.server.ts)
Section titled “App layout ((app)/+layout.server.ts)”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.tsexport 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 */, };};TypeScript types (app.d.ts)
Section titled “TypeScript types (app.d.ts)”The Locals interface declares what hooks attach to every request:
interface Locals { supabase: SupabaseClient<Database>; safeGetSession(): Promise<{ session: Session | null; user: Session["user"] | null; }>;}
interface PageData { session: Session | null; user?: Session["user"] | null;}Route guard system
Section titled “Route guard system”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.
Route categories
Section titled “Route categories”| Category | Examples | Behavior |
|---|---|---|
| Public routes | /auth/callback, /auth/confirm, /api/stripe/webhook | No guard at all — always accessible |
| Public auth routes | /sign-in, /sign-up, /forgot-password, /reset-password | Accessible 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 routes | All 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 |
Guard flow
Section titled “Guard flow”// 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);};Guard helper functions
Section titled “Guard helper functions”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.
Authentication flows
Section titled “Authentication flows”Email + password sign-in
Section titled “Email + password sign-in”User submits login form | POST ?/login v+page.server.ts (login action) | 1. Validate with signInSchema | 2. supabase.auth.signInWithPassword() | 3. redirect(303, "/") vhooks.server.ts guard | session exists + landing page -> redirect to /feedThe 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.
Email + password sign-up
Section titled “Email + password sign-up”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).
Google OAuth
Section titled “Google OAuth”// POST ?/google on /sign-inconst { 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 sign-up page has an identical ?/google action. Both sign-in and sign-up use the same OAuth flow — Supabase creates the account on first OAuth login automatically.
The OAuth flow:
- Server initiates OAuth with Supabase, which returns a Google authorization URL.
- User is redirected to Google to authenticate.
- Google redirects back to
/auth/callbackwith an authorizationcode. - The callback endpoint exchanges the code for a session.
OAuth callback
Section titled “OAuth callback”// src/routes/(auth)/auth/callback/+server.tsexport 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.
Email verification flow
Section titled “Email verification flow”After sign-up, the user is redirected to /verify-email?email=.... This page:
- Displays the email address and a “check your inbox” message.
- Provides a “Resend verification email” button that calls
/auth/resend-verificationviafetch. - Tracks resend state with reactive
$statevariables (resending,resent).
The verification email links to /auth/confirm which verifies the OTP:
// src/routes/(auth)/auth/confirm/+server.tsexport 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.
Resend verification email
Section titled “Resend verification email”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.
Password reset flow
Section titled “Password reset flow”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 }) vUser 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).
Onboarding flow
Section titled “Onboarding flow”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:
/onboardingitself (to prevent redirect loops)/sign-out- All
/api/*routes
The onboarding page at /onboarding:
- Loads the user’s current profile and a list of suggested users to follow.
updateProfileaction — lets the user set their username, bio, caption, website, and upload an avatar to Supabase Storage.completeaction — upserts anonboardsrecord withcompleted: trueand redirects to/feed.
// Marking onboarding completeawait event.locals.supabase .from("onboards") .upsert( { user_id: user.id, completed: true, step: 3 }, { onConflict: "user_id" } );redirect(303, "/feed");RLS and JWT relationship
Section titled “RLS and JWT relationship”Supabase Row Level Security (RLS) policies use the JWT from the user’s session to determine access. The relationship works as follows:
- Login — Supabase Auth issues a JWT containing the user’s
id(asauth.uid()) and any custom claims. - Server requests — When
locals.supabasemakes a database query, the@supabase/ssrclient automatically attaches the session JWT to the request. - RLS evaluation — Postgres evaluates RLS policies using
auth.uid()(extracted from the JWT) to filter rows. For example, a policy likeuser_id = auth.uid()ensures users only see their own data. - 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 vSupabase PostgREST | SET request.jwt.claims = '...' vPostgres RLS policies | auth.uid() = JWT sub claim vFiltered rows returnedBecause 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.
Session access in pages
Section titled “Session access in pages”Once past the hooks, pages and layouts can access the session through event.locals:
// In any +page.server.ts or +layout.server.tsexport 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();Auth route file map
Section titled “Auth route file map”| Route | File | Purpose |
|---|---|---|
/sign-in | src/routes/(auth)/sign-in/+page.server.ts | Email/password login and Google OAuth actions |
/sign-up | src/routes/(auth)/sign-up/+page.server.ts | Account creation and Google OAuth actions |
/verify-email | src/routes/(auth)/verify-email/+page.svelte | Post-signup email verification holding page |
/forgot-password | src/routes/(auth)/forgot-password/+page.svelte | Client-only page that calls the API endpoint |
/reset-password | src/routes/(auth)/reset-password/+page.svelte | Client-only page that calls the API endpoint |
/auth/callback | src/routes/(auth)/auth/callback/+server.ts | OAuth code-to-session exchange |
/auth/confirm | src/routes/(auth)/auth/confirm/+server.ts | Email OTP verification (signup + recovery) |
/auth/forgot-password | src/routes/(auth)/auth/forgot-password/+server.ts | API: send password reset email |
/auth/reset-password | src/routes/(auth)/auth/reset-password/+server.ts | API: update password (rate-limited) |
/auth/resend-verification | src/routes/(auth)/auth/resend-verification/+server.ts | API: resend signup verification email |
/auth/error | src/routes/(auth)/auth/error/+page.svelte | Generic auth error page |
/onboarding | src/routes/(onboarding)/onboarding/+page.server.ts | Profile setup and onboarding completion |
Security measures
Section titled “Security measures”Open redirect prevention
Section titled “Open redirect prevention”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}CSRF on API endpoints
Section titled “CSRF on API endpoints”All +server.ts auth endpoints call csrfProtection(event) before processing. See Forms & Validation for details.
Rate limiting
Section titled “Rate limiting”Password reset and verification resend endpoints implement per-IP rate limiting to prevent abuse. See Forms & Validation for the specific limits.
Cookie security
Section titled “Cookie security”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.
Adding a new protected route
Section titled “Adding a new protected route”- Add a
path.startsWith("/your-route")check toisProtectedRoute()insrc/lib/server/guards.ts. - The onboarding gate automatically applies. If your route should bypass onboarding, add an exclusion in
requiresOnboarding(). - If the route requires admin access, add a check in
isAdminRoute(). - No auth logic is needed in the route’s
+page.server.ts— the hook handles it.