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 An optional group of members inside an organization, which can keep workspaces to itself.
workspace Starterdough The product container, scoped to an organization and optionally to a team.

The app names them the same way everywhere: an organization has members, each with a role, and may have teams of them. A new organization has members and no team; the kit turns off the default team Better Auth would make (Teams are access boundaries).

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 organization’s menu at the top of the sidebar changes the active one, opens its settings and creates another; the sidebar lists the active organization’s workspaces. Routes: /app (workspaces of the active organization), /app/[workspace], /app/[workspace]/settings, /accept-invitation/[id], and the organization’s sections of the settings dialog: /app/organization (settings and usage), /app/organization/members (members, their roles, invitations), /app/organization/teams, /app/billing, and /app/organization/api-keys, /app/organization/webhooks and /app/organization/audit-log for owners and admins. /app/organization/new opens in the same dialog, and /app/team, where members and teams shared one page before, leads to the members. Each is a route of its own, so an email’s link reaches it (App shell).

/app without an active organization is the onboarding: someone who signed up has no organization, and someone who left or deleted the one they had open has none active. It offers, in this order, the organizations the account is in, the invitations waiting for its address (to a verified address only, as Better Auth lists them), and a form for a new one. A first organization’s form starts filled in, with the account’s first name (“Ada’s organization”) and a slug from it, so it is one click. Slugs are unique across the deployment: when the one made from the name is taken, the form adds four random characters to it, while a slug the person typed is theirs to change. Nothing is created at sign-up: someone who signed up to accept an invitation would get an organization they never asked for, counted against the free-organization cap.

Three roles (owner, admin, member), any roles your product adds, 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: every procedure on organization data runs orgScoped(permissions), or orgScopedRow(load, permissions) for a row addressed by its own id, from apps/api/src/rpc/tenancy.ts. They answer FORBIDDEN to non-members and apply the team boundary below before the handler runs, and tenancy.test.ts fails for a signed-in procedure that takes an id, or a list of ids, anywhere in its input without one of them.

Adding a resource is one statement plus role edits in one file. Roles are code, never rows: Better Auth’s dynamic roles are not enabled. A role of your own is a product file away (Roles of your own). Your first tenant-scoped feature adds a resource end to end: notes in a workspace, from the table to a test that another organization cannot reach them.

The kit’s three roles fit most products. One that needs another, such as a reviewer who reads everything and changes nothing, or a client who opens their own project and comments on it, declares it in packages/auth/src/product-roles.ts:

export const productExtraRoles = {
reviewer: {
workspace: ['read'],
document: ['read'],
job: ['read'],
billing: ['read'],
note: ['read'],
},
} as const;

Each entry is the role’s whole grant, on the kit’s statements and yours: without workspace: ['read'] it opens no workspace. On Better Auth’s own resources (organization, member, invitation, team, ac) it holds what a member holds and may not grant more, because Better Auth lets anyone who may change roles give any role but owner: a role that managed people could make itself an admin. It holds nothing owners and admins lack, so an admin may give it and promoting its holder loses nothing. Its name is lowercase letters, digits, - and _, starting with a letter, at most 32 characters. Written as a literal, as above, a role is checked by bun run check, which refuses a role named owner, admin or member, a grant on Better Auth’s resources and an action its statement does not declare; bun run test (productRoleIssues in permissions.test.ts) refuses the rest and names the role and the grant. Whatever gets past both, such as an entry typed Record<string, readonly string[]>, which the check cannot read, is left out of the role permissions.ts builds: it holds nothing on Better Auth’s resources beyond a member’s, and nothing owners and admins lack.

permissions.ts adds each role after the kit’s three. The auth server accepts it in an invitation or a role change, hasPermission and the tenant guards read it, and /app/organization/members offers it in both role pickers. Name it in English and German in productLabels.roles (apps/web/src/lib/product-labels.ts), from messages in product.json: roles: { reviewer: m.product_role_reviewer }. bun run --cwd apps/web check fails while a role has no label.

A member may hold several roles (member,reviewer), as Better Auth stores them, and any one granting an action is enough. Each part of such a list is read as Better Auth reads it, untrimmed, so member, reviewer grants a member’s rights only, and the app shows the part after the space as it is. The auth server now refuses a list with a space in it, or one that names a role twice (400 INVALID_ROLE), so only a row written before can hold one. A role of yours takes a seat like any other, and webhook payloads (member.added, member.role_updated) and audit entries carry its id. Removing a role leaves its holders with no grants at all, and /app/organization/members shows them its bare id: move them first, in a migration of yours (bun run db:generate --custom --name=retire-reviewer), with update member set role = 'member' where role = 'reviewer' and the same for pending invitation rows; a list such as member,reviewer needs rewriting too.

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. An invitation past that limit answers 403 PLAN_LIMIT_REACHED with kind: 'members', limit and plan, the data the API’s LIMIT_REACHED carries, which /app/organization/members phrases; the same code without them is the free-organization cap of an account invited as an owner. Teams are created and managed from /app/organization/teams, and an invitation names one only when the organization has any. A workspace can be scoped to a team, which makes it the team’s.

With ALLOW_SIGN_UP=false an invitation is the only way in besides an administrator: the invitee signs up from its link, with the address it was sent to, and anyone else is refused. Not on a public demo, where every visitor may invite: there closed sign-up refuses invitees too. Anyone who may invite people to an organization can therefore let a new address in, and every signed-in user may create an organization of their own. Keep REQUIRE_EMAIL_VERIFICATION on: without it, whoever knows the invitee’s address can claim the invitation.

An organization holds at most MAX_TEAMS teams (20 by default; 0 lifts the cap), and creating one more answers 400 YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS. A team is a group of people who share workspaces: a department, a client’s staff, the freelancers on a contract. It is not a project. A project is a workspace, and a workspace scoped to a team is that team’s; a product that needs one boundary per project scopes workspaces, not teams.

Better Auth’s organization endpoints accept more than the app sends, and store it as sent unless the kit’s hooks bound it (packages/auth/src/organization-input.ts):

Field Bound
Organization and team name trimmed, control and bidi characters dropped, cut to 100 characters
slug at most 100 characters (the app’s own are 2 to 48, SlugSchema)
logo at most 2,048 characters: a URL, not an image inlined as a data: URL
metadata at most 4,096 bytes of JSON: settings, not documents
An invitation’s address at most 254 characters, the longest mail can be delivered to
A member’s or an invitation’s role each role at most once, as defined, comma-separated without spaces
An invitation’s teams (teamId) each team at most once, and at most 100

A slug, logo or metadata over its bound is refused with 400 ORGANIZATION_FIELD_TOO_LARGE, whose field names it, before anything is written, on create and update alike; a long address with 400 INVALID_EMAIL. A role list that repeats a role or pads one with spaces is refused with 400 INVALID_ROLE, on an invitation, a role change and auth.api.addMember alike. A team list that repeats a team or names more than 100 is refused with 400 INVALID_TEAM_LIST, before Better Auth looks each team up. A name is cut rather than refused. A product that needs more per organization keeps it in a table of its own.

A workspace scoped to a team is visible to that team’s members and to the roles that hold workspace: anyTeam, which are the owner and admin. A workspace with no team is visible to every member. The rule covers everything inside the workspace: its documents and their text, search results and download links, uploads, and its jobs, in lists, in the live job stream and by id. Anyone else gets NOT_FOUND, as if the workspace did not exist, and the organization’s job list and stream leave its jobs out. Leaving a team takes effect on the next request, and an open job stream re-reads the member’s role every 30 seconds. A stream on one workspace ends at that re-read once the member may no longer see the workspace: they left its team, it moved to another team, or it was deleted.

The audit log keeps every entry, and the boundary covers what an entry says. auditLog: read and auditLog: export, which owners and admins hold, return the whole organization’s log. An entry about a workspace, its creation, renames and deletion, and the documents uploaded to it and deleted from it, names them in its metadata. Whoever holds either reads an entry about a workspace they may not see, another team’s without workspace: anyTeam or one your visibility rule hides, with metadata: null: its action, actor, time and target id stay, so the log still says who did what and when. So does an entry about a workspace that is gone, unless the reader sees every workspace: anyTeam, with no visibility rule of yours narrowing it for them. Owners and admins therefore read the whole log, unless a rule of yours hides a workspace from them. An entry of yours that names its workspace in metadata.workspaceId follows the same rule. Webhook endpoint URLs are in no workspace, and every holder reads them.

A job outside any workspace belongs to the member who created it, and to owners and admins. That covers a job created through the API without a workspaceId, and a job whose workspace was deleted: deleting a workspace keeps its jobs, for the credit ledger, but leaves them without a workspace, and they must not fall open to the whole organization. The app never creates a job outside a workspace.

Who may set the boundary:

  • A member may scope a new workspace only to a team they belong to; owners and admins to any team of the organization. A team of another organization is NOT_FOUND.
  • Only owners and admins may move a workspace to another team or open it to the whole organization. The settings page locks the team for everyone else, and the API answers FORBIDDEN.
  • A team that still scopes workspaces cannot be deleted. Better Auth’s POST /api/auth/organization/remove-team answers 400 TEAM_HAS_WORKSPACES, and the workspace’s foreign key refuses the delete as well, so a direct DELETE FROM team fails too. Move or delete the workspaces first. Deleting the organization removes its teams and workspaces together.

Teams are optional, and an organization starts with none. Better Auth would create a default team with every organization, named after it and holding only its creator, and every workspace form then offered it: a workspace scoped to it by mistake was hidden from everyone who joined later. The kit turns it off (teams.defaultTeam.enabled: false in packages/auth/src/server.ts) and lets an organization delete its last team (allowRemovingAllTeams). The workspace forms offer a team only once the organization has one, and “Everyone in the organization” first. An organization created before keeps its default team, now an ordinary one that can be deleted while no workspace is scoped to it. Better Auth also refuses to delete the team a session is on, which the kit never uses; the creator’s session was on the default team, and accepting an invitation to a team puts the invitee’s on it, so /app/organization/teams steps off it before deleting. Owners and admins see every team’s workspaces, but Better Auth lists a team’s members only to the team’s own members, so an admin joins a team to see its roster.

organizations.usage still counts every workspace of the organization, including those a member cannot open: plan limits are per organization.

Teams are the kit’s one boundary inside an organization. A product whose people should see only some workspaces, such as clients who each see their own project, adds a rule in packages/db/src/product-tenancy.ts rather than a team per project:

import { and, eq, exists, type SQL, sql } from 'drizzle-orm';
import { db } from './client';
import { projectAccess } from './schema/project-access';
import { workspaces } from './schema/workspaces';
import type { Viewer } from './tenancy';
export function productWorkspaceVisibility(viewer: Viewer): SQL | undefined {
if (viewer.holds?.('project', 'browse')) return undefined;
if (viewer.userId === null) return sql`false`;
return exists(
db
.select({ one: sql`1` })
.from(projectAccess)
.where(
and(eq(projectAccess.workspaceId, workspaces.id), eq(projectAccess.userId, viewer.userId)),
),
);
}

visibleWorkspace ANDs the condition into the team rule, so a rule hides workspaces the team rule shows and never shows one it hides. Whatever follows the team boundary follows the rule too: the workspace list and lookups, documents, files, search, jobs and the job stream, in lists and by id, answering NOT_FOUND as for another team’s workspace, and an open stream on a workspace ends at its next re-check. It applies to owners and admins unless it says otherwise: here owners, admins and members hold project: browse through productRoles, and a client role of your own leaves it out. An API key sees past it only when granted it in productApiKeyScopes.

viewer.holds(resource, action) answers for the member’s roles or the key’s grants. Write the rule as “restricted unless the viewer holds …”, so a viewer built without holds sees less, not more. A raw route that checks with memberFor or findMember from apps/api/src/rpc/tenancy.ts gets the guards’ member, holds included, and code that checks membership some other way builds its viewer with memberFromRole from the same file. An audit entry about a workspace the rule hides comes without its details, as one about another team’s does, and so, to whomever the rule narrows, does one about a deleted workspace, which it can no longer judge. Webhooks stay outside the rule, as they are outside the team boundary, and organizations.usage counts every workspace. workspaces.create answers with the new workspace even when the rule then hides it from its creator: grant workspace: create only to roles the rule spares. Test the rule against Postgres, as packages/db/src/workspace-visibility.test.ts does with a stand-in.

document has two delete actions. delete, which every role holds, deletes the documents the caller uploaded; deleteAny, which owners and admins hold, deletes anyone’s. A document whose uploader’s account is gone needs deleteAny. mayDelete in @repo/auth/permissions applies the rule in the API and decides whether the app offers Delete on a document, and a product resource whose statement has both actions gets the same rule from it.

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 or organization removes its document rows and atomically queues their stored objects for deletion. A worker retries failed storage operations, so bytes can remain temporarily after the UI reports deletion. The same cleanup applies when an account’s solo organization goes with it. A rolled-back tenant deletion preserves its documents and does not queue their objects.

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.

Because those limits are per organization, an account may also own only FREE_ORGANIZATIONS_PER_OWNER organizations (2, in the catalog) on the free plan; otherwise a new organization would be a fresh set of limits and AI credits. Owning means the owner role, including in an organization someone else created and then made the account an owner of. Organizations on a paid plan do not count, and neither do ones the account belongs to as an admin or member. Becoming an owner of a free organization counts like creating one: accepting an owner invitation, being added as an owner and being promoted to owner are refused at the cap, and an owner invitation to an account already at the cap is refused when it is sent. Otherwise one helper account could collect free organizations by taking ownership and stepping down to admin.

AI credits are the one limit that is used up rather than occupied, and deleting an organization deletes its usage ledger. So a free organization deleted after using AI credits this month keeps counting for each of its owners until the month ends: deleting it through Better Auth leaves one row per owner in Better Auth’s verification table, expiring with the usage period (a platform admin’s deletion leaves none). One that used none frees its place at once. Better Auth’s organizationLimit enforces the cap on POST /api/auth/organization/create, which answers PLAN_LIMIT_REACHED (HTTP 403), and /app/organization/new explains it with a link to billing. It is a cap per account, not per person: someone who signs up again with another address gets another allowance. Like every other limit, it is off while billing is.

Router checks and writes share an organization advisory lock. Seat mutations wrap the complete Better Auth endpoint in one transaction and lock the organization row before counting members plus live invitations. This also covers direct auth.api calls. Accepting a live invitation converts its reserved seat into membership without increasing the total, including after a downgrade. Expired reservations cannot be converted. Creating an organization runs in such a transaction too and locks the creating user’s row before counting, so parallel creates by one account cannot all slip under the free-organization cap. Slug uniqueness is enforced by a unique index.

Owners and admins create organization API keys under /app/organization/api-keys (apiKey: create | read | revoke), so that the organization’s own systems can call the API. A key holds a subset of the statements above, no more than its creator’s role held when it was created, and acts for the organization rather than a person: it is in no team (it sees team workspaces only with workspace: anyTeam) and nobody’s author (it deletes only with deleteAny). It reaches only the procedures whose contract opens them to keys. API keys has the details.

Owners and admins add webhook endpoints under /app/organization/webhooks (webhook: read | manage), so that the organization’s own systems hear about members, workspaces, documents, finished jobs and subscription changes as they happen. Deliveries are signed, retried for about 21 hours, and logged per endpoint for 30 days. Webhooks has the details.

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, and which also records member.add for a member a product adds with auth.api.addMember). Documents, job retries, API keys (api_key.create, api_key.revoke), webhook endpoints (webhook.create, webhook.update, webhook.delete, webhook.secret_rotate, and webhook.disable when one switches itself off or a platform admin switches it off), exports of the log itself (audit_log.export) and a platform admin’s per-organization flag override (flag.override) are recorded too. An organization.update or team.update entry lists the fields the change set (fields) and the stored name and slug among them (changes); a logo or metadata is recorded by its name only, and nothing is copied from the request, which the entry would keep for as long as the log is retained. Subscription changes driven by Stripe webhooks are audited without an actor. Organization deletion is not audited, because the entry would reference a deleted organization: the cascade removes an organization’s audit log with it. A platform admin’s deletion is recorded in the API’s log instead (see Admin).

Owners and admins read it on /app/organization/audit-log with organizations.auditLog, newest first, paged with an opaque cursor (hand back the previous answer’s nextCursor). action filters by an action and everything under it (member matches member.remove and member.role.update), and from and to by time. Export CSV downloads the same filtered entries through organizations.auditLogExport (auditLog: export, owners and admins): the last 30 days unless from says otherwise, at most 366 days and 50,000 entries per file, and ten exports an hour per organization. A cell that would start a spreadsheet formula (=, +, -, @) is prefixed with ', so a name a member typed cannot run in the admin’s spreadsheet.

Your product’s actions get a category of their own in the filter: name the first segment in productLabels.auditCategories (apps/web/src/lib/product-labels.ts), as { report: m.product_audit_category_report }, with the message in both locales. Lowercase letters and _, which is what the action filter accepts, and none of the kit’s; audit-categories.svelte.spec.ts checks both.

An action taken with an API key has no actor: the entry’s api_key_id names the key, and the page shows it as “API key Deploys (starterdough_AbC123…)”. Platform admins read every organization’s log on /admin/organizations/audit-log (admin.auditLog), linked from /admin/organizations, with the same filters and one organization at a time from its row.

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.