Organization Best Practices

Multi-tenant via Better Auth's organization plugin. Every user gets a personal org on signup. Every product table that holds tenant data has organizationId. The active org is stored in the session and scopes most API calls.

Default stance

Org-first, not user-first. New product tables should have organizationId, not userId (or both, but organizationId is the boundary that matters). API keys, webhooks, tasks, billing — all scoped to org. User-scoped legacy code exists for backward compatibility with pre-org keys, but new code should use "org" everywhere it asks for a tenant type.

Use the lifecycle helpers, not raw deletes. When an org is deleted, it cascades into Stripe (cancel subscription), Svix (delete app), sandboxes (stop running tasks), and DB (cascade rows). Skipping the helper means orphaned billing or webhooks.

Default RBAC is owner / admin / member. Don't reach for custom roles or dynamic access control until the product genuinely needs them. The plugin has the surface area for both, but most products don't use it and adding it speculatively just makes the auth surface harder to reason about.

Use this skill when

Adding org-scoped features, managing members/invitations, defining custom roles or RBAC, setting up teams, designing per-org integration boundaries, or debugging owner/admin/member behavior.

Where this repo customizes Better Auth's org plugin

  • Auto-personal-org on signuplib/auth.ts databaseHooks.user.create.after inserts an organization row and a member row with role owner for every new user. Done with raw Drizzle (not the plugin API) because hooks don't have a session context.
  • Invitation emailssendInvitationEmail wired through lib/email.ts + orgInvitationEmail template.
  • Org lifecycle cleanuplib/org-lifecycle.ts exports deleteOrganization() (Stripe + Svix + sandbox + DB cascade) and cancelOrgSubscription().
  • Access control configlib/org-access.ts defines role/resource permissions for fork customization.

Active organization pattern

The active org is stored in the session. Set it after the user picks one:

import { organization } from "@/lib/auth-client"

await organization.setActive({ organizationId })

Many endpoints (listMembers, inviteMember, listInvitations) use the active org when organizationId isn't passed. Use organization.getFullOrganization() to retrieve the active org with members/invitations/teams attached.

Inviting members

import { organization } from "@/lib/auth-client"

// Standard email invite — uses sendInvitationEmail
await organization.inviteMember({ email: "new@example.com", role: "member" })

// Shareable URL — does NOT call sendInvitationEmail; you handle delivery
const { data } = await organization.getInvitationURL({
  email: "new@example.com",
  role: "member",
  callbackURL: "https://yourapp.com/dashboard",
})

Invitations expire in 48h by default; configurable via invitationExpiresIn.

Permission checks

const { data } = await organization.hasPermission({ permission: "member:write" })
if (data?.hasPermission) { /* allowed */ }

checkRolePermission({ role, permissions }) is for static client UI rendering only. For real authorization, use hasPermission (server-side check).

Hard rules

  • Never delete an org via raw db.delete. Use deleteOrganization() from lib/org-lifecycle.ts. Skipping means leaked Stripe subs and orphaned Svix apps.
  • Never remove the last owner. Better Auth refuses, but check before you try — transfer ownership first via updateMemberRole.
  • Never store tenant data without organizationId. Every new table that holds product data should be org-scoped from day one.
  • Never assume org membership in route handlers. withApiKeyAuth resolves the org from the API key, not from anywhere else. If you need to verify a user belongs to an org, check member.
  • Don't enable teams or dynamic access control speculatively. They're real features with real maintenance cost. Add them when the product needs them.

Owner protection

The plugin enforces:

  • Last owner cannot be removed
  • Last owner cannot leave
  • Owner role cannot be removed from the last owner

Always transfer ownership before demoting:

// 1. Promote new owner first
await organization.updateMemberRole({ memberId: "new-owner", role: "owner" })
// 2. Then demote the previous owner
await organization.updateMemberRole({ memberId: "old-owner", role: "admin" })

Where things live

FilePurpose
lib/auth.tsBetter Auth config — organization() plugin + auto-personal-org hook
lib/auth-client.tsExports organization, useActiveOrganization, useListOrganizations
lib/org-access.tsRole/resource permissions — fork customization point
lib/org-lifecycle.tsdeleteOrganization, cancelOrgSubscription — full cascade
lib/schema.tsorganization, member, invitation tables + product tables with organizationId
app/(dashboard)/members/Members management page
scripts/migrate-users-to-orgs.tsOne-time migration helper for pre-org data

Auxiliary content