Better Auth Patterns
Overview
Implement authentication and authorization using Better Auth with TRPC procedures following the project's established patterns.
When to Use This Skill
-
Configuring Better Auth settings
-
Creating protected TRPC procedures
-
Implementing organization/project access control
-
Working with sessions and user roles
-
Setting up OAuth providers
Auth Configuration
// apps/web-app/src/auth/auth.ts import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, organization } from "better-auth/plugins";
export const auth = betterAuth({ baseURL: serverEnv.BETTER_AUTH_URL, secret: serverEnv.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: "pg", schema: { user: usersTable, session: sessionsTable, account: accountsTable, verification: verificationsTable, organization: organizationsTable, member: membersTable, invitation: invitationsTable, }, }), session: { cookieCache: { enabled: true, maxAge: 5 * 60, // 5 minutes cache }, }, socialProviders: { google: { clientId: serverEnv.GOOGLE_CLIENT_ID, clientSecret: serverEnv.GOOGLE_CLIENT_SECRET, }, }, emailAndPassword: { enabled: true }, plugins: [admin(), organization({ sendInvitationEmail: async () => {} })], });
Auth-Related Database Tables
Core Tables:
-
usersTable
-
User accounts with role field (admin | user )
-
sessionsTable
-
Auth sessions with activeOrganizationId
-
accountsTable
-
OAuth accounts (stores access/refresh tokens)
Organization Tables:
-
organizationsTable
-
Organizations
-
membersTable
-
Organization members with role (owner | admin | member )
-
invitationsTable
-
Pending invitations
Project Tables:
-
projectsTable
-
Projects (belong to organizations)
-
projectMembersTable
-
Project members with role (admin | editor | viewer )
TRPC Context & Session
// apps/web-app/src/infrastructure/trpc/init.ts export const createTRPCContext = async ({ headers }: { headers: Headers }) => { const session = await auth.api.getSession({ headers }); return { db, session, headers }; };
Protected Procedure Patterns
Base Auth Procedures
// apps/web-app/src/infrastructure/trpc/procedures/auth.ts const enforceUserIsAuthenticated = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) throw unauthorizedError(); return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, userId: ctx.session.user.id as UserId, }, }); });
const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => { if (!ctx.session?.user || ctx.session.user.role !== "admin") throw unauthorizedError(); return next({ ctx }); });
export const publicProcedure = t.procedure.use(debugMiddleware).use(sentryMiddleware); export const protectedProcedure = publicProcedure.use(enforceUserIsAuthenticated); export const adminProcedure = publicProcedure.use(enforceUserIsAdmin);
Organization Access Procedures
// apps/web-app/src/infrastructure/trpc/procedures/organization.ts import { OrganizationId } from "@project/common";
// Member access - any org member export const protectedOrganizationMemberProcedure = protectedProcedure .input(Schema.standardSchemaV1(Schema.Struct({ organizationId: OrganizationId }))) .use(async function isMemberOfOrganization(opts) { const memberAccess = await opts.ctx.db .select() .from(membersTable) .where( and( eq(membersTable.organizationId, opts.input.organizationId), eq(membersTable.userId, opts.ctx.userId), ), ) .limit(1);
if (memberAccess.length === 0)
throw forbiddenError("You are not a member of this organization");
return opts.next({
ctx: {
member: memberAccess[0],
organizationId: opts.input.organizationId,
},
});
});
// Admin access - org admin/owner only export const protectedOrganizationAdminProcedure = protectedProcedure .input(Schema.standardSchemaV1(Schema.Struct({ organizationId: OrganizationId }))) .use(async function isAdminOfOrganization(opts) { const memberAccess = await opts.ctx.db .select() .from(membersTable) .where( and( eq(membersTable.organizationId, opts.input.organizationId), eq(membersTable.userId, opts.ctx.userId), or(eq(membersTable.role, "admin"), eq(membersTable.role, "owner")), ), ) .limit(1);
if (memberAccess.length === 0) throw forbiddenError("Admin access required");
return opts.next({
ctx: {
member: memberAccess[0],
organizationId: opts.input.organizationId,
},
});
});
Project Access Procedures
// apps/web-app/src/infrastructure/trpc/procedures/project-access.ts
// Single optimized query - checks both org and project membership export const protectedProjectMemberProcedure = protectedProcedure .input(Schema.standardSchemaV1(Schema.Struct({ projectId: ProjectId }))) .use(async function hasProjectAccess(opts) { const result = await ctx.db .select({ projectId: projectsTable.id, organizationId: projectsTable.organizationId, orgMemberRole: membersTable.role, projectMemberRole: projectMembersTable.role, }) .from(projectsTable) .leftJoin( membersTable, and( eq(membersTable.organizationId, projectsTable.organizationId), eq(membersTable.userId, ctx.userId), ), ) .leftJoin( projectMembersTable, and( eq(projectMembersTable.projectId, projectsTable.id), eq(projectMembersTable.userId, ctx.userId), ), ) .where(eq(projectsTable.id, projectId));
// Org admins get automatic project admin access
const isOrgAdmin = data.orgMemberRole === "admin" || data.orgMemberRole === "owner";
if (isOrgAdmin) {
return opts.next({
ctx: {
project,
projectRole: "admin",
orgRole: data.orgMemberRole,
},
});
}
// Check explicit project membership...
});
// Chained procedure for admin-only export const protectedProjectAdminProcedure = protectedProjectMemberProcedure.use( async function requiresProjectAdmin(opts) { if (ctx.orgRole === "admin" || ctx.orgRole === "owner" || ctx.projectRole === "admin") return opts.next({ ctx }); throw forbiddenError("Project admin permissions required"); }, );
Available Procedures Summary
Procedure Access Level Context Provided
publicProcedure
No auth { db, session?, headers }
protectedProcedure
Authenticated { db, session, userId, headers }
adminProcedure
Admin role { db, session, headers }
protectedOrganizationMemberProcedure
Org member { ..., member, organizationId }
protectedOrganizationAdminProcedure
Org admin/owner { ..., member, organizationId }
protectedProjectMemberProcedure
Project access { ..., project, projectRole, orgRole }
protectedProjectAdminProcedure
Project admin { ..., project, projectRole, orgRole }
protectedProjectEditorProcedure
Project editor+ { ..., project, projectRole, orgRole }
Client-Side Auth
// apps/web-app/src/auth/auth-client.ts import { createAuthClient } from "better-auth/react"; import { adminClient, organizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({ betterAuthBaseUrl, plugins: [adminClient(), organizationClient()], });
export const { signIn, signOut, useSession, getSession } = authClient;
// Sign in with redirect export const signInWithEmail = async ( email: string, password: string, callbackURL = "/app/dashboard", ) => { return withAuthRedirect((callbacks) => signIn.email({ email, password }, callbacks), callbackURL); };
export const signInWithGoogle = async (callbackURL = "/app/dashboard") => { return signIn.social({ provider: "google", callbackURL }); };
Admin API Usage
// Using Better Auth server API in TRPC procedures export const router = { setUserAdmin: adminProcedure.mutation(async ({ ctx, input }) => { const users = await auth.api.listUsers({ headers: ctx.headers, query: { searchField: "email", searchValue: input.email, }, }); await auth.api.setRole({ headers: ctx.headers, body: { userId: user.id, role: input.isAdmin ? "admin" : "user", }, }); }),
banUser: adminProcedure.mutation(async ({ ctx, input }) => { await auth.api.banUser({ headers: ctx.headers, body: { userId: input.userId, banReason }, }); }), };
Key Rules
-
Use appropriate procedure for access level needed
-
Org admins get automatic project access - don't duplicate checks
-
Single query for access checks - use JOINs, not multiple queries
-
Pass headers to auth.api calls for session context
-
Chain procedures for more specific access (e.g., protectedProjectAdminProcedure )