15 octobre 2025
Better Auth : structure et permissions avec le plugin Organization

9 minutes de lecture

Après avoir exploré les bases de Better Auth, nous allons aujourd'hui nous intéresser à l’un des plugins que nous avions mentionné : Organization.
Gérer une hiérarchie d’utilisateurs et de permissions dans une application peut vite devenir un casse-tête. Le plugin Organization de Better Auth propose une approche élégante et modulaire : il permet de gérer des structures multi-utilisateurs (équipes, entreprises, projets...), d’y associer des rôles et d’implémenter des permissions.
Voici la liste des points que nous allons aborder :
- la gestion du state entre client et serveur,
- la personnalisation du modèle Organization,
- la mise en place d’un système de permissions avancé,
- et enfin l’activation du mode Teams, pour les cas les plus complexes.
Séparation du state entre serveur et client
Un point de base essentiel à comprendre est que auth
(serveur) et authClient
(client) fonctionnent comme deux systèmes séparés, ils ne partagent pas d’état synchronisé automatiquement.
👉 En pratique :
- Les actions exécutées via
auth.api
ne mettent pas à jour automatiquement les hooks client (ex.useSession
,useActiveOrganization
). - Il faut rafraîchir explicitement l’état client après toute action ayant un impact sur la session, l’organisation ou les rôles. Par exemple, le hook
useActiveOrganization()
propose unrefetch()
pour rafraîchir les données de l’organisation active côté client.
Ce comportement découle de la séparation entre client et serveur : Better Auth ne synchronise pas automatiquement ces deux états. Cela donne au développeur un contrôle total sur le rafraîchissement côté client. Similaire à d’autres librairies d’authentification modernes, la synchronisation automatique du state n’est généralement pas implémentée pour des raisons de performance et de sécurité.
Personnaliser le modèle Organization
Les tables nécessaires au plugin Organization sont personnalisables : il est possible de les renommer ainsi que leurs champs ou d'ajouter des champs supplémentaires. Le CLI de Better Auth se chargera ensuite de générer le schéma correspondant dans votre base de données via la commande npx @better-auth/cli generate
(ici, nous utilisons Prisma avec son adapter).
Dans cet exemple, nous allons renommer Organization
en Project
et son champ name
en title
(mais dans le reste de l'article, nous continuerons à parler de organization
).
plugins: [
organization({
schema: {
organization: {
modelName: 'project', // `Organization` sera renommé `Project`
fields: {
name: 'title', // `name` sera renommé `title`
},
},
member: {
additionalFields: {
name: {
// On ajoute un champ `name` à la table `Member`
type: 'string',
input: true,
required: false,
},
},
},
},
}),
]
// Voici les tables générées
model Project {
id String @id
title String
slug String?
logo String?
createdAt DateTime
metadata String?
members Member[]
invitations Invitation[]
@@unique([slug])
@@map("project")
}
model Member {
id String @id
organizationId String
project Project @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role String
createdAt DateTime
name String?
@@map("member")
}
model Invitation {
id String @id
organizationId String
project Project @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String
role String?
status String
expiresAt DateTime
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
@@map("invitation")
}
⚠️ Le renommage n’affecte pas les noms des méthodes SDK (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,
})
Mettre en place un système de permissions
C’est sans doute l'une des parties les plus intéressantes du plugin Organization : Better Auth fournit un système d’Access Control flexible avec son createAccessControl
. Il s’appuie sur une approche déclarative de la sécurité, où chaque rôle se voit attribuer un ensemble d’actions autorisées sur des entités précises.
Résumé des termes :
- Entité → représente un objet sur lequel on agit (
organization
,member
,invitation
). - Action → représente ce qu’on peut faire sur une entité (
update
,delete
, etc.). - Permission → est la combinaison entité + action (un
member
avec l'actioncreate
sur l'entitéinvitation
peut créer une invitation). - Rôle → est un ensemble de permissions accordées à un utilisateur.
Exemple de fichier de configuration :
Voici un exemple minimal pour déclarer des actions par entité et composer des rôles.
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 }
Points importants
- Un utilisateur peut avoir plusieurs rôles à la fois.
- Vous pouvez définir vos propres permissions (ex.
update-name
). - Si vous définissez un
ac
et desroles
personnalisés, les permissions par défaut de Better Auth sont écrasées. Better Auth s’attend à retrouver certaines actions pour ses checks internes ; il faut donc réintroduire les actions de base si vous souhaitez utiliser les méthodes natives du plugin :organization: ["update", "delete"]
member: ["create", "update", "delete"]
invitation: ["create", "cancel"]
Si une de ces actions est absente, les rôles qui ne la possèdent pas ne pourront pas exécuter les méthodes correspondantes (auth.organization.update
, auth.organization.inviteMember
, etc.) : elles seront considérées comme non autorisées.
Permission personnalisée
Vous vous demandez peut-être pourquoi nous avons à la fois update
et update-name
dans nos permissions. Intuitivement, on pourrait penser que update
couvre toutes les actions de mise à jour mais dans le plugin Organization, update
fait uniquement référence à la méthode updateMemberRole, c'est-à-dire au changement de rôle d'un membre.
Cela s'explique par le fait que la table Member
ne contient aucun champ que l'on souhaiterait modifier par défaut autre que role
. Notre permission personnalisée update-name
n'aura donc de sens que si nous ajoutons un champ name
à la table Member
et que nous implémentons la logique métier correspondante (via nos propres routes ou actions serveur).
Vérifier les permissions côté client et côté serveur
Une bonne pratique consiste à effectuer un double check :
- Côté client → pour la logique d’interface (afficher/masquer un bouton)
- Côté serveur → pour la sécurité et la source de vérité
Côté client
authClient.organization.checkRolePermission({
permissions: { organization: ['update'] },
role: 'owner',
})
Cette méthode est synchrone et ne dépend pas du serveur. Elle est idéale pour ajuster l’UI selon les permissions du rôle mais ne prend pas en compte les éventuelles mises à jour récentes (changement de rôle, révocation, etc.).
Côté serveur
Le serveur reste la source de vérité.
Les rôles et permissions étant stockés en base, il faut toujours valider côté serveur :
await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: { organization: ['update'] },
},
})
Voici un petit utilitaire ainsi que notre implémentation côtés client/serveur :
import { statement } from '@/lib/auth/permissions'
export type Entities = keyof typeof statement
export type PermissionFor<E extends Entities> = (typeof statement)[E][number]
// Côté Client
export const hasClientPermission = <E extends Entities, P extends PermissionFor<E>>(
role: Role,
entity: E,
permission: P
) => {
return authClient.organization.checkRolePermission({
permissions: { [entity]: [permission] },
role: role!,
})
}
// Côté Serveur
'use server'
import { auth } from '@/lib/auth'
import { ERROR_MESSAGES } from '@/utils/constants'
import { Entities, PermissionFor } from '@/utils/organization/permissions'
import { headers } from 'next/headers'
export const hasServerPermission = async <E extends Entities, P extends PermissionFor<E>>(
entity: E,
permission: P
) => {
try {
const { error, success } = await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: { [entity]: [permission] },
},
})
if (error) {
throw new Error(error)
}
if (success === false) {
throw new Error(ERROR_MESSAGES.YOU_DONT_HAVE_PERMISSION_TO_DO_THIS_ACTION)
}
return success
} catch (error) {
if (error instanceof Error) {
throw new Error(error.message)
}
console.error('[🔴 hasPermission]: ', error)
throw new Error(ERROR_MESSAGES.AN_ERROR_OCCURRED)
}
}
Gérer les invitations
Better Auth fournit un système intégré de gestion des invitations qui permet d’ajouter de nouveaux membres à une organisation.
Les méthodes SDK
Côté client (authClient
) comme côté serveur (auth
), le fonctionnement est identique :
// Côté client
// ⚠️ Pensez à rafraîchir l’état côté client si nécessaire
const { data, error } = await authClient.organization.listInvitations({
query: {
organizationId: "organization-id",
},
});
// Côté serveur, la méthode équivalente à inviteMember s’appelle `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",
});
// Côté serveur
// ⚠️ N'oubliez pas d'inclure les headers
// Sinon la méthode renverra un statut 401 "UNAUTHORIZED"
const data = await auth.api.listInvitations({
headers: await headers(),
query: {
organizationId: "organization-id",
},
});
await auth.api.createInvitation({
headers: await headers(),
body: {
email: "example@gmail.com",
role: "member",
organizationId: "org-id",
resend: true,
teamId: "team-id",
},
});
await auth.api.acceptInvitation({
headers: await headers(),
body: {
invitationId: "invitation-id",
},
});
await auth.api.rejectInvitation({
headers: await headers(),
body: {
invitationId: "invitation-id",
},
});
await auth.api.cancelInvitation({
headers: await headers(),
body: {
invitationId: "invitation-id",
},
});
Callbacks
Après la création d'une invitation avec inviteMember
(authClient) ou createInvitation
(auth), Better Auth exécute automatiquement le callback sendInvitationEmail qui permet d’envoyer un e-mail personnalisé contenant l’invitationId
.
Il existe également un callback onInvitationAccepted déclenché lorsque l'utilisateur accepte une invitation :
import { betterAuth } from 'better-auth'
import { organization } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [
organization({
async sendInvitationEmail(data) {
// Gestion de votre envoi d'invitation par email
},
async onInvitationAccepted(data) {
// Gestion après qu'un utilisateur accepte une invitation
},
}),
],
})
⚙️ De notre côté, dans le mail d'invitation, nous avons inclus un lien vers une page de redirection /accept-invitation/[invitationId]
. Elle redirige l'utilisateur vers la page de connexion s’il n’est pas authentifié ou vers une page de gestion dédiée où il peut accepter ou refuser son invitation.
Pour aller plus loin : le mode Teams
Better Auth permet d’ajouter un niveau hiérarchique supplémentaire grâce au flag de configuration teams. Chaque organisation pourra ainsi avoir différentes équipes, chacune avec ses propres membres et rôles.
C’est particulièrement utile pour les applications complexes où :
- une entreprise regroupe plusieurs départements,
- un utilisateur a des rôles différents selon l’équipe,
- on souhaite affiner les permissions sans multiplier les organisations.
import { betterAuth } from 'better-auth'
import { organization } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [
organization({
teams: { enabled: true },
}),
],
})
Better Auth gère automatiquement les relations entre organization
, team
et member
: les utilisateurs pourront faire partie de plusieurs équipes au sein d'une même organisation. Le système d’Access Control s’applique également au niveau de l’équipe, permettant par exemple de limiter une action à un rôle dans une seule équipe.
Conclusion
Le plugin Organization de Better Auth offre une flexibilité impressionnante, il permet de construire un système d’accès robuste et lisible. Le système de permissions est entièrement extensible, vous pouvez créer vos propres entités (project
, team
, workspace
) et définir autant de types d’actions que nécessaire pour coller à vos besoins métier !
Avec la configuration Teams, on peut modéliser des structures hiérarchiques avancées sans perdre la simplicité de son API.
Better Auth ne se limite donc pas à l’authentification, c’est un véritable socle d’autorisation moderne, extensible et agréable à utiliser.