Plugins

Organization

Organizations, members, invitations, teams, roles, and active-org sessions.

The Organization plugin adds multi-tenant organizations: membership, email invitations, an active organization on the session, optional teams, and optional dynamic access-control roles. It pairs with an access-control policy so members can be checked for permissions before they act.

Installation

Add the plugin to your server

Pass organization() to your Kernia config. Supply a send_invitation callback to deliver invite emails; enable teams and dynamic_access_control if you need them.

auth.py
from kernia import KerniaOptions
from kernia.auth import init
from kernia.plugins.organization import organization

async def send_invitation(invitation: dict) -> None:
    await email_client.send(
        to=invitation["email"],
        subject="You've been invited to Acme",
        html=f'Accept: https://app.example.com/accept/{invitation["id"]}',
    )

auth = init(KerniaOptions(
    database=adapter,
    secret=os.environ["KERNIA_SECRET"],
    base_url=os.environ["KERNIA_BASE_URL"],
    plugins=[
        organization(
            teams=True,
            dynamic_access_control=True,
            invitation_expires_in=60 * 60 * 24 * 2,
            send_invitation=send_invitation,
        ),
    ],
))

Add the client plugin

Add the organization client plugin:

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { organizationClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: "/api/auth",
  plugins: [organizationClient()],
});

Usage

Create an organization

const { data: org } = await authClient.organization.create({
  name: "Acme",
  slug: "acme",
  logo: "https://example.com/logo.png",
});

The creator becomes the owner. Use checkSlug({ slug }) first if you need to validate availability.

List and set the active organization

const { data: orgs } = await authClient.organization.list();
await authClient.organization.setActive({ organizationId: "org_123" });

setActive writes activeOrganizationId onto the current session so subsequent calls scope to that org.

Get full details

const { data } = await authClient.organization.getFullOrganization({
  organizationId: "org_123",
});

Returns the organization with its members, invitations, and teams.

Invite and accept members

await authClient.organization.inviteMember({
  email: "user@example.com",
  role: "member",
  organizationId: "org_123",
});

await authClient.organization.acceptInvitation({ invitationId: "inv_123" });

inviteMember creates a pending invitation and calls your send_invitation callback.

Manage members

const { data } = await authClient.organization.listMembers({ organizationId: "org_123" });
await authClient.organization.updateMemberRole({ memberId: "mem_123", role: "admin" });
await authClient.organization.removeMember({ memberIdOrEmail: "user@example.com", organizationId: "org_123" });
await authClient.organization.leave({ organizationId: "org_123" });

Teams

Available when the server is configured with teams=True.

await authClient.organization.createTeam({ name: "Platform", organizationId: "org_123" });
const { data: teams } = await authClient.organization.listTeams({ organizationId: "org_123" });

Check permissions

const { data } = await authClient.organization.hasPermission({
  permissions: { member: ["delete"] },
});

// Check a hypothetical role without an extra round-trip:
const allowed = authClient.organization.checkRolePermission({
  role: "admin",
  permissions: { member: ["delete"] },
});

hasPermission checks the current member against the server-side policy; checkRolePermission evaluates a role locally.

Options

OptionTypeDefaultDescription
teamsboolFalseRegister team CRUD routes and add team / teamMember tables.
dynamic_access_controlboolFalseRegister role CRUD routes and add the organizationRole table.
invitation_expires_inint172800 (2 days)Invitation lifetime in seconds.
send_invitationasync (invitation) -> NoneNoneCalled with the invitation row after it is created — deliver the email here.

Schema

Core tables are always added; teams and dynamic access control add more when enabled. The session table is extended with activeOrganizationId (and activeTeamId when teams are on).

organization
id
stringrequired
Primary key.
name
stringrequired
Display name.
slug
stringrequired
Unique URL-safe identifier.
logo
stringoptional
Logo URL.
metadata
jsonoptional
Arbitrary metadata.
createdAt
daterequired
Creation time.
updatedAt
dateoptional
Last update time.
member
id
stringrequired
Primary key.
organizationId
stringrequired
References organization.id.
userId
stringrequired
References user.id.
role
stringrequired
Member role (default "member").
createdAt
daterequired
Join time.
invitation
id
stringrequired
Primary key.
organizationId
stringrequired
References organization.id.
email
stringrequired
Invited email address.
role
stringrequired
Role granted on acceptance.
status
stringrequired
pending | accepted | rejected | cancelled | expired.
inviterId
stringrequired
References user.id of the inviter.
teamId
stringoptional
Target team (only when teams are enabled).
expiresAt
dateoptional
Invitation expiry.
createdAt
daterequired
Creation time.

When teams=True, the plugin also adds team and teamMember. When dynamic_access_control=True, it adds organizationRole (role name + JSON permissions per organization).

The active organization lives on the session via setActive. Routes that mutate membership scope to it, so set an active organization before calling member or team operations that omit an explicit organizationId.