Organizations, teams, workspaces
The model
Section titled “The model”| 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].
Roles, defined once
Section titled “Roles, defined once”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:
- the auth server (
organization({ ac, roles })); - the auth client —
checkRolePermission()gates UI, so members do not see buttons they cannot use; - the API router —
requireMember(userId, organizationId, permissions)on every workspace procedure, answeringFORBIDDENto non-members.
Adding a resource is one statement plus role edits in one file. Custom or dynamic roles are not enabled.
Invitations and teams
Section titled “Invitations and teams”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
Section titled “Workspaces”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.
Plan limits
Section titled “Plan limits”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.
Audit log
Section titled “Audit log”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.