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.

MC
Martin Coll

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:

  • allowUserToCreateOrganization can be a boolean or a function of the user, so you can gate organization creation on a plan.
  • creatorRole is the role the creator gets: owner or admin.
  • membershipLimit caps members per organization.
  • sendInvitationEmail is yours to implement. The plugin hands you the invitation; you build the link from data.id and 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 instead

If 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 Role row per organization holds a list of resource:action permission 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-id header 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.