Skip to content

Authentication

Authentication is Supabase-managed (auth.users), wrapped in @supabase/ssr for cookie-based sessions across server functions and route loaders. app.profile holds display data separately from the auth identity (see Database schema).

getClaims(), never getSession(), server-side

Section titled “getClaims(), never getSession(), server-side”

Every trusted server-side check calls supabase.auth.getClaims(), which validates the JWT signature on every call. getSession() reads the session from cookies without revalidating it and must never be used server-side — this is enforced by convention, not by a lint rule, so keep it in mind when adding a new authenticated code path. authMiddleware (apps/app/src/server/middleware/auth.ts) is the one place this happens for tenant-data server functions; it resolves context.user from the validated claims and redirects to /login if they don’t validate.

The root route’s beforeLoad also calls a plain getUser() helper (apps/app/src/lib/auth.ts, same getClaims() call) to populate router context for UX-level guards — this is Layer 1 of the double-guard pattern described in API surface; it is not itself a security boundary.

/login (apps/app/src/routes/login.tsx) offers two paths, both against the RLS-scoped client:

  • Password sign-insupabase.auth.signInWithPassword().
  • Magic linksupabase.auth.signInWithOtp({ email }). The server function always returns { ok: true } regardless of outcome — no email enumeration: whether or not the address is registered, the user sees the same “if that email is registered, a link is on its way” message.

Supabase’s confirmation email links to {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email. apps/app/src/routes/auth/confirm.tsx implements this as a route loader calling a server function that runs supabase.auth.verifyOtp({ token_hash, type }) — this sets the session cookies server-side — then redirects to /dashboard on success or /login on failure.

This route-loader-plus-server-function shape (rather than a raw HTTP route handler) is the current TanStack Start equivalent of a server route; both /auth/confirm and /auth/callback use it, and both are listed in SETUP_EXEMPT in __root.tsx so they can complete even before the app is initialised (see First-run setup).

apps/app/src/routes/auth/callback.tsx exchanges an OAuth code for a session with supabase.auth.exchangeCodeForSession(code) (again setting cookies server-side), then redirects the same way as /auth/confirm. No OAuth provider is currently configured in this deployment — the route exists so wiring one up later is just Supabase Dashboard configuration, no code change.

getSupabaseServerClient() (apps/app/src/lib/supabase/server.ts) wraps createServerClient from @supabase/ssr, reading and writing the auth cookies of the current request via TanStack Start’s getCookies/setCookie. Every server function and route loader that needs the caller’s identity or RLS gets a fresh client this way — never a client held across requests. The service-role client (getSupabaseServiceRoleClient()) is a plain createClient with persistSession: false and reads the secret key from process.env at call time so it’s never inlined into a browser bundle (see API surface for when service-role is actually justified).