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.
Sign-in flows
Section titled “Sign-in flows”/login (apps/app/src/routes/login.tsx) offers two paths, both against the
RLS-scoped client:
- Password sign-in —
supabase.auth.signInWithPassword(). - Magic link —
supabase.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.
Email confirmation — GET /auth/confirm
Section titled “Email confirmation — GET /auth/confirm”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).
OAuth callback — GET /auth/callback
Section titled “OAuth callback — GET /auth/callback”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.
@supabase/ssr cookie sessions
Section titled “@supabase/ssr cookie sessions”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).