Authentication
Kit uses better-auth with the Drizzle adapter. One config file drives web and mobile.
Overview
better-auth handles:
- Email/password sign up and login
- GitHub OAuth social login
- Session management via cookies (web) and SecureStore (mobile)
- Organizations - every user gets a personal org, invites and roles included
- Admin - user banning, impersonation, role management
Architecture
graph TD
A[User Login] --> B[better-auth handler]
B --> C[Session Cookie]
C --> D[tRPC Context]
D --> E[Protected Procedures]
E --> F[Database Queries]Key Components
- Server config -
packages/api/src/auth/auth.tsdefines the whole auth system - Route handler -
apps/web/src/app/api/auth/[...all]/route.tsmounts it - Web client -
apps/web/src/lib/auth-client.ts - Mobile client -
apps/mobile/src/utils/auth.ts
Server Configuration
The auth instance lives in packages/api/src/auth/auth.ts:
// packages/api/src/auth/auth.ts
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
baseURL: baseUrl,
plugins: [
oAuthProxy({
currentURL: baseUrl,
productionURL: process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: baseUrl,
}),
expo(), // mobile support
organization(), // multi-tenant orgs
admin(), // user management
nextCookies(), // Next.js cookie handling
],
emailAndPassword: {
enabled: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID ?? "",
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
},
},
trustedOrigins: ["expo://"],
});Notes:
oAuthProxymakes GitHub OAuth work on Vercel preview deployments - callbacks route through the production URL.nextCookiessets session cookies from server actions and route handlers.- The secret comes from
BETTER_AUTH_SECRET(auto-detected by better-auth).
Route Handler
All auth endpoints mount under /api/auth/*:
// apps/web/src/app/api/auth/[...all]/route.ts
import { auth } from "@repo/api/auth/auth";
export const GET = auth.handler;
export const POST = auth.handler;Database Hooks
New users get a personal organization automatically. New sessions get an active organization:
databaseHooks: {
user: {
create: {
after: async (user) => {
await createDefaultOrganization(user);
},
},
},
session: {
create: {
before: async (session) => {
return await setActiveOrganization(session);
},
},
},
},Server-Side Session
auth.ts exports cached helpers for server components:
// Cached per-request with React cache()
export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }));
export const getOrganization = cache(async (query) =>
auth.api.getFullOrganization({ query, headers: await headers() }),
);Usage in a server component:
import { getSession } from "@repo/api/auth/auth";
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect("/auth/login");
}
return <div>Welcome {session.user.email}</div>;
}tRPC Integration
The tRPC context resolves the session from request headers:
// packages/api/src/trpc.ts
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth.api.getSession({
headers: opts.headers,
});
return {
session,
db,
};
};protectedProcedure guarantees a logged-in user:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to access this resource",
});
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});Organization membership is checked per-router - see ensureOrganizationAccess in packages/api/src/todo/todo-router.ts.
Client Usage
Web
// apps/web/src/lib/auth-client.ts
import { adminClient, organizationClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [adminClient(), organizationClient()],
});Sign in, sign up, and OAuth from any client component:
import { authClient } from "@/lib/auth-client";
// Email/password
await authClient.signIn.email({ email, password });
await authClient.signUp.email({ email, password, name });
// GitHub OAuth
await authClient.signIn.social({ provider: "github", callbackURL: "/dashboard" });
// Session hook
const { data: session } = authClient.useSession();The auth pages live in apps/web/src/app/(auth)/ - login, register, password reset. The shared form is apps/web/src/app/(auth)/_components/auth-form.tsx.
Mobile
The Expo client stores sessions in SecureStore:
// apps/mobile/src/utils/auth.ts
import * as SecureStore from "expo-secure-store";
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: getBaseUrl(),
plugins: [
expoClient({
scheme: "expo",
storagePrefix: "expo",
storage: SecureStore,
}),
],
});Environment Variables
# Generate with `openssl rand -base64 32`
BETTER_AUTH_SECRET=your-secret
# GitHub OAuth app (https://github.com/settings/developers)
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secretCustomization
Adding OAuth Providers
Add to socialProviders in packages/api/src/auth/auth.ts:
socialProviders: {
github: { ... },
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
},
},Then call authClient.signIn.social({ provider: "google" }).
Schema Changes
Auth tables are generated. After changing plugins, regenerate:
cd packages/db && pnpm generate:auth-schemaThen re-add .enableRLS() to each table (see the note in packages/db/src/drizzle-schema-auth.ts) and run pnpm db:push.
Security Notes
- Validate all inputs with Zod at tRPC boundaries
- Authorization lives in tRPC procedures, not the database - always check org membership in routers
- Never commit secrets; use different GitHub OAuth apps for dev and prod
Next Steps
- Database setup - Learn about database architecture
- API development - Build tRPC procedures
- Social auth - Add more OAuth providers