Better Auth organizations: multi-tenant setup, step by step
Set up multi-tenant organizations with the Better Auth organization plugin: server, client, invitations, roles. Plus how useDeploy does it without it.
To make a Better Auth app multi-tenant you add the organization() plugin on the
server and organizationClient() on the client, run the schema migration, and
use authClient.organization.* to create organizations, invite members and
check permissions. That gives you organizations, members, invitations and three
default roles with very little code. This guide walks through that setup with
[email protected], then explains what useDeploy does instead: it does not
use the plugin, and runs its own tenancy module on top of Better Auth sessions.
Every API below follows the organization plugin docs, and the snippets type-check against 1.6.9.
1. Add the plugin on the server
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
// database, emailAndPassword, ...
plugins: [
organization({
allowUserToCreateOrganization: true, // default
creatorRole: 'owner', // default
membershipLimit: 100, // default
sendInvitationEmail: async (data) => {
const link = `https://app.example.com/accept-invitation/${data.id}`;
await sendEmail(data.email, link); // your email provider
},
}),
],
});What the options do, per the docs:
allowUserToCreateOrganizationcan be a boolean or a function of the user, so you can gate organization creation on a plan.creatorRoleis the role the creator gets:owneroradmin.membershipLimitcaps members per organization.sendInvitationEmailis yours to implement. The plugin hands you the invitation; you build the link fromdata.idand send it.
2. Create the tables
The plugin adds tables for organizations, members and invitations, plus fields on the session that track the active organization. Generate or apply them with the Better Auth CLI:
npx auth migrate # applies the schema to your database
npx auth generate # writes the schema for your ORM insteadIf you use Prisma, use generate and fold the output into your own
schema.prisma, then run your normal Prisma migration.
3. Add the client plugin
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [organizationClient()],
});4. Use it
// create an organization and switch to it
await authClient.organization.create({ name: 'Acme', slug: 'acme' });
await authClient.organization.setActive({ organizationSlug: 'acme' });
// invite someone
await authClient.organization.inviteMember({ email: '[email protected]', role: 'member' });
// the invitee accepts, using the id from the email link
await authClient.organization.acceptInvitation({ invitationId });
// ask whether the current user can do something
await authClient.organization.hasPermission({ permissions: { project: ['create'] } });On the server the same operations live under auth.api, for example
auth.api.hasPermission({ headers, body: { permissions } }). Every request is
made as a signed-in user, so Better Auth knows who is asking.
5. Custom roles with access control
The default roles are owner (full control), admin (everything except deleting
the organization or changing the owner) and member (read-only). To model your
own resources, declare a statement and build roles from it:
import { createAccessControl } from 'better-auth/plugins/access';
import { defaultStatements, adminAc } from 'better-auth/plugins/organization/access';
const statement = {
...defaultStatements,
project: ['create', 'share', 'update', 'delete'],
} as const;
const ac = createAccessControl(statement);
const member = ac.newRole({ project: ['create'] });
const admin = ac.newRole({ project: ['create', 'update'], ...adminAc.statements });
const owner = ac.newRole({
project: ['create', 'share', 'update', 'delete'],
...adminAc.statements,
});
// pass the same ac and roles to both sides
organization({ ac, roles: { owner, admin, member } });
organizationClient({ ac, roles: { owner, admin, member } });These roles are defined in code, so changing one means a deploy. The docs also
describe a dynamic access control option, backed by an organizationRole table,
for roles created at runtime.
When the plugin is enough
Use it when you want multi-tenancy quickly and your needs match its model: a small fixed set of roles, one active organization per session, and invitations by email. It is maintained with the auth library, so upgrades stay in step.
How useDeploy does it
useDeploy's Better Auth instance enables three plugins, magicLink,
twoFactor and passkey, in
apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts. It does
not enable organization(). Better Auth owns identity: sign-in, sessions, 2FA.
Tenancy is a separate bounded context, apps/server/src/modules/tenancy, with
its own Prisma models: Organization, OrganizationMember, Role and
Invitation.
We built it that way for requirements the plugin's model does not cover out of the box:
- Roles are data. A
Rolerow per organization holds a list ofresource:actionpermission strings. Owners edit roles at runtime. System roles (owner,admin,member) are created with each organization and cannot be edited or deleted. - The organization travels with each request. The active organization is not
stored on the session. The client sends an
x-organization-idheader with every request, and middleware checks the caller's membership on each one. Scripts and API clients can target any organization the user belongs to without switching session state. - Invariants live in the domain. The last owner cannot be demoted or removed; that check runs in a serializable transaction so two concurrent requests cannot both win. A role can only be granted by someone who already holds its permissions.
- Invitation tokens are never stored. Only a SHA-256 hash of the token is in the database.
- Suspension. A suspended or deleted organization is rejected on every request, even for valid members.
- API keys are pinned to one organization with their own scoped permissions.
The cost is that you maintain this code, which is why it is a module of a boilerplate and not something we would suggest writing for a weekend project. The request flow, the middleware and the permission catalog are covered in RBAC in Express with per-organization permissions. The data-leak side is in Multi-tenancy in Prisma without RLS, and the agency angle in Reusing one multi-tenant base across every client.
Frequently asked questions
Do I need the organization plugin to have multi-tenancy with Better Auth? No. Better Auth only authenticates the user. You can model organizations in your own tables, as useDeploy does, and use the session's user id to look up memberships.
Does the plugin support more than one organization per user?
Yes. setActive exists to switch between the organizations a user belongs to; the
chosen one is the active organization for the session.
Where does the active organization live in useDeploy?
In the client (a persisted store) and in the x-organization-id header. The
server validates it against the membership table on every request and never
trusts it on its own.
Which Better Auth version is this written for?
1.6.9, the version in useDeploy's package.json. Check the docs for newer
options.
useDeploy ships Better Auth plus the tenancy module described above, with organizations, members, invitations and roles. See the Better Auth integration and the multi-tenancy feature.