Skip to content

Organizations, teams, workspaces

Entity Owned by What it is
organization Better Auth The tenant and billing entity. Owns members, invitations and teams.
team Better Auth Groups members inside an organization.
workspace Director The product container, scoped to an organization and optionally to a team.

Plans and limits live in packages/billing; the Stripe plugin bills the organization and syncs seats with membership (see Billing).

New sessions start in the user’s first organization; the sidebar switcher changes the active one. Routes: /app (workspaces of the active organization), /app/[workspace], /app/[workspace]/settings, /app/organization (settings, usage, audit log), /app/organization/new, /app/team (members, invitations, teams), /accept-invitation/[id].

Three roles — owner, admin, member — and the permission statements behind them are defined in packages/auth/src/permissions.ts (browser-safe) and consumed in three places:

  1. the auth server (organization({ ac, roles }));
  2. the auth clientcheckRolePermission() gates UI, so members do not see buttons they cannot use;
  3. the API routerrequireMember(userId, organizationId, permissions) on every workspace procedure, answering FORBIDDEN to non-members.

Adding a resource is one statement plus role edits in one file. Custom or dynamic roles are not enabled.

Invitations are emailed as /accept-invitation/<id> links and expire after 7 days. The landing page asks the recipient to sign in or sign up first. An expired invitation stops counting against the plan’s seat limit the moment it lapses, and the daily purge (03:17 UTC) deletes it — so two lapsed invites can never leave a free organization unable to invite anybody again. Teams are created and managed from /app/team; a workspace can be scoped to a team.

Workspaces have a name and a slug that is unique per organization. Slugs are lowercase letters, numbers and dashes, 2–48 characters, and may not shadow the app’s own routes under /app/ (admin, api, billing, new, organization, settings, team). The create and update forms validate the very schema the API enforces — WorkspaceCreateInputSchema and WorkspaceUpdateInputSchema from the contract.

Deleting a workspace deletes its documents’ stored objects as well as their rows: the cascade would otherwise remove the only record of each storage_key and leave the bytes in the bucket for ever. Deleting an organization does the same across its whole key prefix, and so does deleting an account whose solo organization goes with it.

Procedures: workspaces.list, get, create, update, delete — see API: RPC and REST.

Limits from the plan catalog are enforced server-side: workspaces, AI credits and document storage in the router, seats in the organization hooks. Exceeding one returns LIMIT_REACHED (HTTP 403) with { kind: 'workspaces' | 'members' | 'aiCredits' | 'storageBytes', limit, plan }, and the app shows a message with a link to billing. organizations.usage reports the plan, its limits and the current counts; storage is per workspace and reported by workspaces.get and documents.list instead. With billing off, plan and limits are null and nothing is capped.

Each check and the insert it guards run inside one transaction holding pg_advisory_xact_lock(hashtext(organizationId)), so N parallel requests cannot all read the pre-insert count and all pass. Slug uniqueness sits on a real unique index on top of that.

The audit_log table records who did what per organization: every workspace mutation (from the router) and every organization mutation (from a Better Auth hooks.after middleware, which sees the acting session). Owners and admins read it with organizations.auditLog — newest first, paged with an opaque cursor (hand back the previous answer’s nextCursor) — on /app/organization. Subscription changes driven by Stripe webhooks are audited without an actor. Organization deletion is not audited, because the entry would reference a deleted organization.

An action taken during an impersonation session records both identities — the impersonated user as the actor, the platform administrator in impersonated_by, which organizations.auditLog returns as impersonatedBy — so an operator’s change is never attributed to the person they were impersonating. AUDIT_LOG_RETENTION_DAYS (365 by default, 0 = for ever) bounds how long entries live, which also bounds how long a deleted account’s address survives: the table denormalises actor_email on purpose.