The Organization plugin provides everything you need to support multi-tenant applications: organizations, team management, member invitations, role-based permissions, and fine-grained access control.
Installation
Add the plugin to your auth config
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
organization()
]
})
Migrate the database
Run the migration or generate the schema to add the necessary tables.Add the client plugin
import { createAuthClient } from "better-auth/client"
import { organizationClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
organizationClient()
]
})
Organizations
Create an organization
const { data, error } = await authClient.organization.create({
name: "My Organization",
slug: "my-org",
logo: "https://example.com/logo.png", // optional
})
By default, any authenticated user can create an organization. To restrict creation, configure allowUserToCreateOrganization:
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
organization({
allowUserToCreateOrganization: async (user) => {
const subscription = await getSubscription(user.id)
return subscription.plan === "pro"
},
}),
],
})
List organizations
import { authClient } from "@/lib/auth-client"
function App() {
const { data: organizations } = authClient.useListOrganizations()
return (
<div>
{organizations?.map((org) => (
<p key={org.id}>{org.name}</p>
))}
</div>
)
}
const organizations = await authClient.organization.list()
Active organization
The active organization is the workspace the user is currently working in. Set it with:
await authClient.organization.setActive({
organizationId: "org-id",
})
Retrieve it reactively:
function App() {
const { data: activeOrganization } = authClient.useActiveOrganization()
return <p>{activeOrganization?.name}</p>
}
Get full organization details
const { data: org } = await authClient.organization.getFullOrganization({
organizationId: "org-id", // optional, defaults to active org
membersLimit: 100, // optional
})
Update an organization
await authClient.organization.update({
organizationId: "org-id",
data: {
name: "Updated Name",
slug: "updated-slug",
logo: "https://example.com/new-logo.png",
},
})
Delete an organization
await authClient.organization.delete({
organizationId: "org-id",
})
To disable deletion entirely:
organization({
disableOrganizationDeletion: true,
})
Invitations
Set up invitation emails
Provide a sendInvitationEmail callback so Better Auth can send invitation emails:
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
organization({
async sendInvitationEmail(data) {
const inviteLink = `https://example.com/accept-invitation/${data.id}`
await sendEmail({
to: data.email,
subject: `Join ${data.organization.name}`,
body: `You've been invited by ${data.inviter.user.name}. Click here: ${inviteLink}`,
})
},
}),
],
})
Invite a member
await authClient.organization.inviteMember({
email: "colleague@example.com",
role: "member", // "owner" | "admin" | "member"
organizationId: "org-id", // optional, defaults to active org
})
Accept an invitation
On the page that handles the invitation link:
await authClient.organization.acceptInvitation({
invitationId: "invitation-id", // from the URL
})
The user must be signed in before accepting an invitation. If requireEmailVerificationOnInvitation is enabled, their email must also be verified.
Other invitation actions
// Cancel a sent invitation (as the inviter)
await authClient.organization.cancelInvitation({ invitationId: "id" })
// Reject a received invitation
await authClient.organization.rejectInvitation({ invitationId: "id" })
// Get invitation details
await authClient.organization.getInvitation({ id: "id" })
// List invitations for the active org
await authClient.organization.listInvitations({ organizationId: "org-id" })
// List invitations for the current user
await authClient.organization.listUserInvitations()
Members
List members
const { data } = await authClient.organization.listMembers({
organizationId: "org-id", // optional
limit: 50,
offset: 0,
sortBy: "createdAt",
sortDirection: "desc",
})
Update a member’s role
await authClient.organization.updateMemberRole({
memberId: "member-id",
role: "admin",
organizationId: "org-id", // optional
})
Remove a member
await authClient.organization.removeMember({
memberIdOrEmail: "user@example.com",
organizationId: "org-id", // optional
})
Add a member directly (server-only)
await auth.api.addMember({
body: {
userId: "user-id",
role: "member",
organizationId: "org-id",
},
})
Leave an organization
await authClient.organization.leave({
organizationId: "org-id",
})
Roles and permissions
Default roles
The organization plugin ships with three built-in roles:
| Role | Capabilities |
|---|
owner | Full control, including deleting the organization and transferring ownership |
admin | Full control except deleting the organization or changing the owner |
member | Read-only access to organization data |
A user can have multiple roles. Multiple roles are stored as comma-separated strings in the database.
Default permissions
| Resource | Actions |
|---|
organization | update, delete |
member | create, update, delete |
invitation | create, cancel |
Custom permissions
Create an access controller
import { createAccessControl } from "better-auth/plugins/access"
const statement = {
project: ["create", "share", "update", "delete"],
} as const
export const ac = createAccessControl(statement)
Import from better-auth/plugins/access (not better-auth/plugins) to keep bundle sizes small.
Define roles
import { createAccessControl } from "better-auth/plugins/access"
import { defaultStatements, adminAc } from "better-auth/plugins/organization/access"
const statement = {
...defaultStatements,
project: ["create", "share", "update", "delete"],
} as const
export const ac = createAccessControl(statement)
export const member = ac.newRole({
project: ["create"],
})
export const admin = ac.newRole({
project: ["create", "update"],
...adminAc.statements,
})
export const owner = ac.newRole({
project: ["create", "update", "delete"],
...adminAc.statements,
})
Pass roles to the plugin
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
import { ac, owner, admin, member } from "@/auth/permissions"
export const auth = betterAuth({
plugins: [
organization({
ac,
roles: { owner, admin, member },
}),
],
})
import { createAuthClient } from "better-auth/client"
import { organizationClient } from "better-auth/client/plugins"
import { ac, owner, admin, member } from "@/auth/permissions"
export const authClient = createAuthClient({
plugins: [
organizationClient({
ac,
roles: { owner, admin, member },
}),
],
})
Check permissions
From the client (async, server-verified):
const { data: canCreate } = await authClient.organization.hasPermission({
permissions: {
project: ["create"],
},
})
From the server:
await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
project: ["create"],
},
},
})
Client-side role check (synchronous, no network call):
const canDelete = authClient.organization.checkRolePermission({
permissions: { organization: ["delete"] },
role: "admin",
})
checkRolePermission runs synchronously on the client and does not include dynamic roles. Use hasPermission for authoritative checks.
Lifecycle hooks
You can run custom logic before and after organization operations using organizationHooks.
Organization hooks
organization({
organizationHooks: {
beforeCreateOrganization: async ({ organization, user }) => {
return {
data: {
...organization,
metadata: { customField: "value" },
},
}
},
afterCreateOrganization: async ({ organization, member, user }) => {
await setupDefaultResources(organization.id)
},
beforeUpdateOrganization: async ({ organization, user, member }) => {
return { data: { ...organization } }
},
afterUpdateOrganization: async ({ organization }) => {
await syncToExternalSystem(organization)
},
},
})
Member hooks
organization({
organizationHooks: {
beforeAddMember: async ({ member, user, organization }) => {
return { data: { ...member } }
},
afterAddMember: async ({ member, user, organization }) => {
await sendWelcomeEmail(user.email, organization.name)
},
beforeRemoveMember: async ({ member, user, organization }) => {
await cleanupUserResources(user.id, organization.id)
},
afterRemoveMember: async ({ member, user, organization }) => {
await logMemberRemoval(user.id, organization.id)
},
beforeUpdateMemberRole: async ({ member, newRole, user, organization }) => {
return { data: { role: newRole } }
},
afterUpdateMemberRole: async ({ member, previousRole, user, organization }) => {
await logRoleChange(user.id, previousRole, member.role)
},
},
})
Invitation hooks
organization({
organizationHooks: {
beforeCreateInvitation: async ({ invitation, inviter, organization }) => {
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days
return { data: { ...invitation, expiresAt } }
},
afterCreateInvitation: async ({ invitation, inviter, organization }) => {
await sendCustomInvitationEmail(invitation, organization)
},
afterAcceptInvitation: async ({ invitation, member, user, organization }) => {
await setupNewMemberResources(user, organization)
},
},
})
Hook error handling
Throwing an error in a before hook aborts the operation:
import { APIError } from "better-auth/api"
organization({
organizationHooks: {
beforeAddMember: async ({ member, user, organization }) => {
const violations = await checkUserViolations(user.id)
if (violations.length > 0) {
throw new APIError("BAD_REQUEST", {
message: "User has pending violations and cannot join organizations",
})
}
},
},
})
Dynamic access control
Dynamic access control lets you create and manage roles at runtime, stored in the database rather than defined in code.
organization({
ac,
dynamicAccessControl: {
enabled: true,
maximumRolesPerOrganization: 10, // optional limit
},
})
organizationClient({
dynamicAccessControl: { enabled: true },
})
Create a dynamic role
await authClient.organization.createRole({
role: "billing-manager",
permission: {
project: ["create", "update"],
},
organizationId: "org-id",
})
Manage dynamic roles
// List roles
await authClient.organization.listOrgRoles({ organizationId: "org-id" })
// Get a specific role
await authClient.organization.getOrgRole({ roleName: "billing-manager" })
// Update a role
await authClient.organization.updateOrgRole({
roleName: "billing-manager",
data: { permission: { project: ["create", "update", "delete"] } },
})
// Delete a role
await authClient.organization.deleteRole({ roleName: "billing-manager" })