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 Starterdough 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 client: checkRolePermission() gates UI, so members do not see buttons they cannot use;
  3. the API router: requireMember(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 as soon as it lapses, and the daily purge (03:17 UTC) deletes it. 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 to 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 same schema the API enforces: WorkspaceCreateInputSchema and WorkspaceUpdateInputSchema from the contract.

Deleting a workspace deletes its documents’ stored objects as well as their rows. 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 parallel requests cannot all read the pre-insert count and all pass. Slug uniqueness sits on a 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 on /app/organization, newest first, paged with an opaque cursor (hand back the previous answer’s nextCursor). 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, and the platform administrator in impersonated_by, which organizations.auditLog returns as impersonatedBy. AUDIT_LOG_RETENTION_DAYS (365 by default, 0 = for ever) bounds how long entries live. The table stores actor_email, so retention also bounds how long a deleted account’s address survives.