Authentication Overview
Complete authentication system with email/password, magic links, social providers, MFA, and session management. Built on Better Auth with Prisma.
The kit provides production-ready authentication built on Better Auth, a TypeScript-first authentication library. All auth state is stored in your Postgres database via Prisma, giving you full control over user data.
Authentication in the TanStack Start Prisma SaaS Kit handles user identity (who you are), while authorization (what you can do) is managed through roles and permissions.
Features
| Feature | Status | Environment Variable |
|---|---|---|
| Email/Password | Enabled by default | VITE_AUTH_PASSWORD=true |
| Magic Link | Disabled by default | VITE_AUTH_MAGIC_LINK=true |
| Passkey (WebAuthn) | Disabled by default | ENABLE_PASSKEY (provision + migrate), then VITE_AUTH_PASSKEY=true (UI) |
| Social Providers | Optional | Google is wired by default via GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
| Multi-Factor Authentication | Optional per user | Enabled in user settings |
| Email Verification | Required by default | Built-in |
| Session Management | Automatic | Cookie-based |
Quick Start
Get the Current Session
Use getSession() from @kit/better-auth/context inside any server context - a server function, a route loader, or a beforeLoad guard. It reads the request headers via getRequestHeaders(), so it only runs on the server. To protect a route, throw a redirect from a beforeLoad guard:
apps/web/src/lib/auth/guards.ts
import { redirect } from '@tanstack/react-router';import { fetchSession } from './session.functions';export async function requireAuth({ location,}: { location: { href: string };}) { const session = await fetchSession(); if (!session) { throw redirect({ href: `/auth/sign-in?redirect=${encodeURIComponent(location.href)}`, }); } return { session };}fetchSession is a createServerFn({ method: 'GET' }) wrapper around getSession() (see apps/web/src/lib/auth/session.functions.ts); the wrapper lets beforeLoad/loader read the session server-side even during client-side navigation, where getRequestHeaders() is unavailable. getSession() is memoized per request (via a WeakMap keyed on the request headers), so multiple calls within the same request are efficient.
Client-Side Session
Use authClient.useSession() in any client-rendered component:
'use client';import { authClient } from '@kit/better-auth/client';export function UserAvatar() { const { data: session, isPending } = authClient.useSession(); if (isPending) return <Skeleton />; if (!session) return null; return <Avatar name={session.user.name} />;}Authentication Routes
| Route | Purpose |
|---|---|
/auth/sign-in | Sign in with email/password, magic link, or social |
/auth/sign-up | Create new account |
/auth/password-reset | Request password reset email |
/auth/verify | MFA verification (when enabled) |
/password-reset | Set new password from the email link |
Architecture
The authentication system is split across packages:
| Package | Purpose |
|---|---|
@kit/better-auth | Core auth configuration, session context, plugins |
@kit/auth | UI components (sign-in forms, OAuth buttons, MFA) |
@kit/action-middleware | Server function protection (authAction, organizationAction, adminAction) |
Session Data Structure
interface Session { user: { id: string; name: string; email: string; image: string | null; emailVerified: boolean; createdAt: Date; updatedAt: Date; role: string | null; // 'super-admin' for admins }; session: { id: string; userId: string; expiresAt: Date; activeOrganizationId: string | null; };}Topics
- Sign In - Email/password, magic link, and social authentication
- Sign Up - User registration and account creation
- Password Reset - Self-service password recovery
- Session Handling - Protect routes and access session data
- Multi-Factor Authentication - TOTP-based two-factor authentication
Environment Variables
Essential auth configuration:
apps/web/.env
# Required: 32+ character secret for signing tokensBETTER_AUTH_SECRET=your-secret-key-min-32-characters# Auth methods (enable/disable)VITE_AUTH_PASSWORD=trueVITE_AUTH_MAGIC_LINK=false# Passkey: shows the UI only. Provision it first via ENABLE_PASSKEY in# packages/better-auth/src/auth.features.ts (regenerate schema + migrate).VITE_AUTH_PASSKEY=false# Google OAuth (optional)GOOGLE_CLIENT_ID=your-google-client-idGOOGLE_CLIENT_SECRET=your-google-client-secret# Base URL for auth callbacksVITE_SITE_URL=http://localhost:3000Common Patterns
Protect a Server Function
Build protected server functions with the middleware factories from @kit/action-middleware. authAction() pre-binds the authenticated middleware so context.user is guaranteed; continue the standard createServerFn chain with .validator() and .handler():
apps/web/src/lib/.../update-profile.ts
import { z } from 'zod';import { authAction } from '@kit/action-middleware';export const updateProfileAction = authAction() .validator(z.object({ name: z.string().min(1) })) .handler(async ({ data, context }) => { // context.user is guaranteed to exist await updateUser(context.user.id, data); return { success: true }; });Call it from a client component with TanStack Query: useMutation({ mutationFn: updateProfileAction }).
Require Organization Context
requireActiveOrganizationId() throws a redirect to the app home path when no organization is active. Call it inside a server function, then load it from a route loader:
organization.functions.ts
import { createServerFn } from '@tanstack/react-start';import { requireActiveOrganizationId } from '@kit/better-auth/context';export const fetchMembers = createServerFn({ method: 'GET' }).handler( async () => { // Redirects to the app home path if not in org context const orgId = await requireActiveOrganizationId(); return loadMembers(orgId); },);Check Admin Status
Use isUserAdmin() from @kit/auth/require-admin inside a server function to check admin status without redirecting, then expose it to the UI via a loader:
apps/web/src/lib/auth/session.functions.ts
import { createServerFn } from '@tanstack/react-start';import { isUserAdmin } from '@kit/auth/require-admin';export const fetchIsAdmin = createServerFn({ method: 'GET' }).handler(() => isUserAdmin(),);In a component, render the admin link with Link from @tanstack/react-router based on the loader data:
import { Link } from '@tanstack/react-router';<nav> <Link to="/dashboard">Dashboard</Link> {isAdmin && <Link to="/admin">Admin</Link>}</nav>;Common Pitfalls
These issues come up frequently in production deployments:
- Missing
BETTER_AUTH_SECRET: The secret must be at least 32 characters. A short or missing secret causes cryptic token errors. - Callback URL mismatch: Social providers require exact callback URLs. Make sure
VITE_SITE_URLmatches your deployment URL, includinghttps://in production. - Cookie issues across subdomains: If deploying to multiple subdomains, you may need to configure the cookie domain in Better Auth settings.
- Session not found after deploy: Clear browser cookies after changing
BETTER_AUTH_SECRET, as old sessions become invalid. - MFA bypassed via magic link: By design, magic link and social auth skip MFA. If you require MFA for all users, disable these methods.
Frequently Asked Questions
Which authentication methods are enabled by default?
Is email verification required?
How do I add social sign-in?
Where is auth data stored?
How do sessions work?
Can users enable MFA?
This authentication system is part of the TanStack Start Prisma SaaS Kit.
Next: Sign In →