AccueilClients

Applications et sites

  • Application métiersIntranet, back-office...
  • Applications mobilesAndroid & iOS
  • Sites InternetSites marketings et vitrines
  • Expertises techniques

  • React
  • Expo / React Native
  • Next.js
  • Node.js
  • Directus
  • TypeScript
  • Open SourceBlogContactEstimer

    15 octobre 2025

    Better Auth: Structure and Permissions with the Organization Plugin

    8 minutes reading

    Better Auth: Structure and Permissions with the Organization Plugin
    🇫🇷 This post is also available in french

    After exploring the fundamentals of Better Auth, let's take a closer look at one of its key plugins: Organization.

    Managing a hierarchy of users and permissions in an application can quickly become a headache. The Organization plugin from Better Auth offers an elegant and modular approach — it lets you manage multi-user structures (teams, companies, projects...), assign roles, and implement permissions.

    Here's what we'll cover:

    • managing state between client and server,
    • customizing the Organization model,
    • setting up an advanced permissions system,
    • and enabling Teams mode for more complex use cases.

    Separation of Client and Server State

    A key concept to understand is that auth (server) and authClient (client) act as two separate systems, they do not share synchronized state automatically.

    👉 In practice:

    • Actions executed via auth.api do not automatically update client-side hooks (e.g. useSession, useActiveOrganization).
    • You must explicitly refresh client state after any action that affects the session, organization, or roles. For instance, the useActiveOrganization() hook exposes a refetch() method to refresh the active organization's client-side data.

    This behavior stems from the separation between client and server: Better Auth does not automatically synchronize these two states, giving developers full control over client-side refreshes. Similar to other modern authentication libraries, automatic synchronization is typically avoided for performance and security reasons.

    Customizing the Organization Model

    The tables required by the Organization plugin are customizable. You can rename them and their fields or add extra fields. The Better Auth CLI will then generate the appropriate schema in your database using the command npx @better-auth/cli generate (here we use Prisma with its adapter).

    In this example, we'll rename Organization to Project and its field name to title (though we'll continue referring to it as organization throughout the article).

    plugins: [
      organization({
        schema: {
          organization: {
            modelName: 'project', // `Organization` will be renamed `Project`
            fields: {
              name: 'title', // `name` will be renamed `title`
            },
          },
          member: {
            additionalFields: {
              name: {
                // Adds a `name` field to the `Member` table
                type: 'string',
                input: true,
                required: false,
              },
            },
          },
        },
      }),
    ]
    

    ⚠️ Renaming affects only the database mapping, not the SDK method names (authClient.organization.*, auth.api.*).

    // ✅ Correct
    const { data, error } = await authClient.organization.delete({
      organizationId: activeOrganization!.id,
    })
    
    // ❌ Incorrect
    const { data, error } = await authClient.project.delete({
      projectId: activeProject!.id,
    })
    

    Setting Up a Permission System

    Role management: permissions UI screenshot

    This is arguably one of the most interesting parts of the Organization plugin: Better Auth provides a flexible Access Control system using its createAccessControl. It relies on a declarative security approach, where each role is assigned a set of authorized actions on specific entities.

    Terminology summary:

    • Entity → represents an object being acted upon (organization, member, invitation).
    • Action → represents what can be done on an entity (update, delete, etc.).
    • Permission → is the combination of an entity and an action (for example, a member with the create action on the invitation entity can create an invitation).
    • Role → is a set of permissions assigned to a user.

    Example configuration file:

    Here’s a minimal example to define actions per entity and compose roles.

    import { createAccessControl } from 'better-auth/plugins/access'
    
    const statement = {
      organization: ['update', 'delete'],
      member: ['create', 'update', 'delete', 'update-name'],
      invitation: ['create', 'cancel'],
    } as const
    
    const ac = createAccessControl(statement)
    
    const member = ac.newRole({
      member: ['update-name'],
    })
    
    const admin = ac.newRole({
      member: ['update', 'delete', 'update-name'],
      invitation: ['create', 'cancel'],
    })
    
    const owner = ac.newRole({
      organization: ['update', 'delete'],
      member: ['update', 'delete', 'update-name'],
      invitation: ['create', 'cancel'],
    })
    
    export { statement, ac, owner, admin, member }
    

    Key Points

    • A user can have multiple roles.
    • You can define your own permissions (e.g. update-name).
    • If you define a custom ac and roles, Better Auth's default permissions are overridden. Since Better Auth expects certain actions for its internal checks, you should reintroduce the base actions if you want to use built-in plugin methods:
      • organization: ["update", "delete"]
      • member: ["create", "update", "delete"]
      • invitation: ["create", "cancel"]

    If one of these actions is missing, roles that lack it won’t be able to execute the corresponding methods (auth.organization.update, auth.organization.inviteMember, etc.) — they’ll be considered unauthorized.

    Custom Permission

    You might wonder why we have both update and update-name permissions. Intuitively, you'd expect update to cover all update actions, but in the Organization plugin, update refers specifically to the updateMemberRole method, which changes a member's role.

    This is because the Member table doesn't have any fields you'd want to edit by default other than role. Our custom update-name permission will only make sense if we add a name field to the Member table and implement the corresponding business logic (through our own routes or server actions).

    Checking Permissions on Client and Server

    A good practice is to perform a double check:

    • Client-side → for UI logic (show/hide elements).
    • Server-side → for security and truth validation.

    Client-side

    authClient.organization.checkRolePermission({
      permissions: { organization: ['update'] },
      role: 'owner',
    })
    

    This method is synchronous and does not depend on the server. It's ideal for adjusting UI elements based on role permissions but does not reflect recent changes (e.g. role updates or revocations).

    Server-side

    The server remains the source of truth. Since roles and permissions are stored in the database, you should always validate server-side:

    await auth.api.hasPermission({
      headers: await headers(),
      body: {
        permissions: { organization: ['update'] },
      },
    })
    

    Here's a small utility along with client/server implementations:

    import { statement } from '@/lib/auth/permissions'
    
    export type Entities = keyof typeof statement
    export type PermissionFor<E extends Entities> = (typeof statement)[E][number]
    

    Managing invitations

    Better Auth provides a built-in system for managing invitations, allowing you to add new members to an organization.

    Invitations: interface screenshot

    SDK Methods

    On both the client (authClient) and the server (auth), the behavior is identical:

    // Client side
    // ⚠️ Remember to refresh the client state if needed
    
    const { data, error } = await authClient.organization.listInvitations({
      query: {
      organizationId: "organization-id",
      },
    });
    
    // On the server, the equivalent method to inviteMember is `createInvitation`
    const { data, error } = await authClient.organization.inviteMember({
      email: "example@gmail.com",
      role: "member",
      organizationId: "org-id",
      resend: true,
      teamId: "team-id",
    });
    
    const { data, error } = await authClient.organization.acceptInvitation({
      invitationId: "invitation-id",
    });
    
    await authClient.organization.rejectInvitation({
      invitationId: "invitation-id",
    });
    
    await authClient.organization.cancelInvitation({
      invitationId: "invitation-id",
    });
    
    

    Callbacks

    After creating an invitation using inviteMember (authClient) or createInvitation (auth), Better Auth automatically executes the sendInvitationEmail callback, which can be used to send a custom email containing the invitationId.

    There's also an onInvitationAccepted callback triggered when a user accepts an invitation:

    import { betterAuth } from 'better-auth'
    import { organization } from 'better-auth/plugins'
    
    export const auth = betterAuth({
      plugins: [
        organization({
          async sendInvitationEmail(data) {
            // Handle your custom invitation email sending logic here
          },
          async onInvitationAccepted(data) {
            // Handle post-acceptance logic here
          },
        }),
      ],
    })
    

    ⚙️ In our case, the invitation email includes a link to a dedicated redirect page /accept-invitation/[invitationId]. This page redirects the user either to the login page (if unauthenticated) or to a dedicated invitation management page, where they can accept or reject the invitation.

    Going Further: Teams Mode

    Better Auth allows you to add an additional hierarchical level using the teams configuration flag. Each organization can thus have different teams, each with its own members and roles.

    This is particularly useful for complex applications where:

    • a company includes multiple departments,
    • a user has different roles per team,
    • you want to refine permissions without multiplying organizations.
    import { betterAuth } from 'better-auth'
    import { organization } from 'better-auth/plugins'
    
    export const auth = betterAuth({
      plugins: [
        organization({
          teams: { enabled: true },
        }),
      ],
    })
    

    Better Auth automatically manages relationships between organization, team, and member. The same Access Control system now applies at the team level, allowing you to restrict actions to a specific team role.

    Conclusion

    The Organization plugin in Better Auth offers impressive flexibility, allowing you to build a robust and readable access system. The permissions system is fully extensible, letting you define your own entities (project, team, workspace) and as many action types as needed for your business logic.

    With Teams mode, you can model advanced hierarchical structures without losing the simplicity of the API.

    Better Auth isn't just authentication, it's a modern authorization framework, extensible and delightful to use.

    À découvrir également

    Premier Octet vous accompagne dans le développement de vos projets avec nextjs

    Discuter de votre projet nextjs