Roles and Permissions

Role-based access control for organizations using @kit/rbac and Better Auth.

The kit uses a configuration-driven role system powered by the @kit/rbac package and Better Auth's permission system.

Configuration

All role and permission settings are configured in a single file: packages/rbac/src/rbac.config.ts.

This file is the only file you need to edit for all RBAC customizations.

import { defineRBACConfig } from './core/factory';
export default defineRBACConfig({
// Add custom resources
resources: { PROJECT: 'project' },
// Add custom actions (optional)
actions: { ARCHIVE: 'archive' },
// Add custom roles with hierarchy levels
roles: { moderator: 30 },
// Define which actions are valid for each custom resource
accessController: {
project: ['create', 'read', 'update', 'delete', 'archive'],
},
// Define permissions for each role on custom resources
permissions: {
owner: { project: ['create', 'read', 'update', 'delete', 'archive'] },
admin: { project: ['create', 'read', 'update'] },
member: { project: ['read'] },
},
});

Default resources (organization, member, invitation, billing, ac) and actions (create, read, update, delete, cancel) are automatically included.

Default Roles

Three roles are included by default:

RoleLevelDescription
owner100Full control over organization
admin50Can manage members and billing
member10Basic organization access

Higher hierarchy levels grant more authority. A role can only manage roles with lower levels.

Typed Role Constants

The DefaultRole type provides autocomplete and type safety:

import { type DefaultRole } from '@kit/rbac';
// DefaultRole = 'owner' | 'admin' | 'member'
function setRole(role: DefaultRole) {
// TypeScript provides autocomplete
}
// Also accepts custom roles when needed
function setAnyRole(role: DefaultRole | string) {
// Works with custom roles from Dynamic Access Control
}

Role Hierarchy Functions

The @kit/rbac package provides utilities for working with roles.

import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
// Admin can manage member (50 > 10)
canTargetRole('admin', 'member', false, ROLE_HIERARCHY);
// Returns: true
// Admin cannot manage owner (50 < 100)
canTargetRole('admin', 'owner', false, ROLE_HIERARCHY);
// Returns: false
// Equal roles cannot manage each other by default
canTargetRole('admin', 'admin', false, ROLE_HIERARCHY);
// Returns: false
// With allowEqual, same-level targeting is permitted
canTargetRole('admin', 'admin', true, ROLE_HIERARCHY);
// Returns: true

Client-Side Role Checks

Use canTargetRole from @kit/rbac to check if a user can manage another member based on role hierarchy:

import { canTargetRole } from '@kit/rbac';
export function MemberActions({ member, currentUserRole, roleHierarchy }) {
const canManage = canTargetRole(currentUserRole, member.role, false, roleHierarchy);
return (
<DropdownMenu>
{canManage && (
<>
<DropdownMenuItem onClick={() => changeRole(member.id)}>
Change Role
</DropdownMenuItem>
<DropdownMenuItem onClick={() => removeMember(member.id)}>
Remove Member
</DropdownMenuItem>
</>
)}
</DropdownMenu>
);
}

The canTargetRole function checks if one role can manage another based on hierarchy levels:

import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
// Admin (50) can target member (10)
canTargetRole('admin', 'member', false, ROLE_HIERARCHY); // true
// Admin (50) cannot target owner (100)
canTargetRole('admin', 'owner', false, ROLE_HIERARCHY); // false
// With allowEqual=true, same-level targeting is permitted (useful for invitations)
canTargetRole('admin', 'admin', true, ROLE_HIERARCHY); // true

Server-Side Permission Checks

Better Auth provides server-side authorization through the auth.api.hasPermission() method. On the server, request headers come from getRequestHeaders() (@tanstack/react-start/server). Wrap the check in a createServerFn so route loaders can call it during SSR and client-side navigation.

Check Permissions

import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { auth } from '@kit/better-auth';
// Check if the current user can invite members
export const fetchCanCreateInvitation = createServerFn({ method: 'GET' }).handler(
async () => {
const { success } = await auth.api.hasPermission({
headers: getRequestHeaders(),
body: {
permissions: {
invitation: ['create'],
},
},
});
return success;
},
);

Check Multiple Permissions

import { getRequestHeaders } from '@tanstack/react-start/server';
import { auth } from '@kit/better-auth';
const { success: canAdminister } = await auth.api.hasPermission({
headers: getRequestHeaders(),
body: {
permissions: {
member: ['create', 'update', 'delete'],
invitation: ['create', 'update'],
},
},
});

Protect Mutations

Mutations are server functions built from the action factories in @kit/action-middleware. Continue the createServerFn chain with .validator(schema) and .handler(...); the handler's context is typed by the middleware.

import { organizationAction, withFeaturePermission } from '@kit/action-middleware';
import { createUpdateOrganizationService } from '@kit/organization-core/services';
import { UpdateOrganizationSchema } from './update-organization.schema';
export const updateOrganization = organizationAction()
.middleware([withFeaturePermission({ organization: ['update'] })])
.validator(UpdateOrganizationSchema)
.handler(async ({ data, context }) => {
// context.organizationId is available; the user has organization:update
const service = createUpdateOrganizationService();
await service.updateOrganization({
organizationId: context.organizationId,
name: data.name,
});
});

Server Action Authorization Middleware

The action factories pre-bind authorization middleware to a createServerFn builder. Layer extra gates with the native .middleware([...]) step.

withFeaturePermission

Checks resource permissions via Better Auth:

import { authAction, withFeaturePermission } from '@kit/action-middleware';
export const updateOrg = authAction()
.middleware([withFeaturePermission({ organization: ['update'] })])
.validator(UpdateOrgSchema)
.handler(async ({ data, context }) => {
// context.user is set; the user has organization:update permission
});

withMinRole

Checks minimum role level using the hierarchy:

import { authAction, withMinRole } from '@kit/action-middleware';
export const deleteOrg = authAction()
.middleware([withMinRole('owner')])
.validator(DeleteOrgSchema)
.handler(async ({ data, context }) => {
// Only owners can reach here
});

organizationAction

Pre-configured factory with organization context (context.organizationId, context.role) and no extra permission checks:

import { organizationAction } from '@kit/action-middleware';
export const listMembers = organizationAction()
.validator(ListMembersSchema)
.handler(async ({ data, context }) => {
const { organizationId, role } = context;
// List members for the organization
});

Conditional Rendering via the Route Loader

Permission checks run server-side in the route loader (calling a server function), and the component reads the result with Route.useLoaderData():

import { createFileRoute } from '@tanstack/react-router';
import { fetchCanCreateInvitation } from '#/lib/auth/account.functions';
export const Route = createFileRoute('/_authenticated/settings/members')({
loader: async () => {
const canInvite = await fetchCanCreateInvitation();
return { canInvite };
},
component: MembersPage,
});
function MembersPage() {
const { canInvite } = Route.useLoaderData();
return (
<div>
<h1>Team Members</h1>
{canInvite && <InviteMemberButton />}
<MembersList />
</div>
);
}

Default Permissions

Each role has predefined permissions:

PermissionOwnerAdminMember
Organization ReadYesYesYes
Organization UpdateYesYesNo
Organization DeleteYesNoNo
Member CreateYesYesNo
Member UpdateYesYesNo
Member DeleteYesYesNo
Invitation ManageYesYesView only

Customizing Roles

Adding Custom Roles

Add custom roles in packages/rbac/src/rbac.config.ts:

export default defineRBACConfig({
roles: {
moderator: 30, // Between admin (50) and member (10)
viewer: 5, // Below member
},
});

Custom roles are merged with the default hierarchy (owner: 100, admin: 50, member: 10). They appear in invitation forms and role dropdowns automatically.

Better Auth Permission Templates

Better Auth maps roles to permission templates based on hierarchy level:

LevelTemplatePermissions
>= 100ownerFull organization control
>= 50adminManage members and billing
< 50memberBasic access

Roles below level 50 (like moderator: 30 or viewer: 5) receive the member permission template. For finer control, add custom resources and actions.

Adding Resources and Actions

All customizations happen in packages/rbac/src/rbac.config.ts:

export default defineRBACConfig({
// Step 1: Add custom resources
resources: {
PROJECT: 'project',
API_KEY: 'apiKey',
},
// Step 2: Add custom actions (optional)
actions: {
EXPORT: 'export',
IMPORT: 'import',
},
// Step 3: Define which actions are valid for each custom resource
accessController: {
project: ['create', 'read', 'update', 'delete', 'export'],
apiKey: ['create', 'read', 'delete'],
},
// Step 4: Define permissions for each role on custom resources
permissions: {
owner: {
project: ['create', 'read', 'update', 'delete', 'export'],
apiKey: ['create', 'read', 'delete'],
},
admin: {
project: ['create', 'read', 'update', 'export'],
apiKey: ['create', 'read'],
},
member: {
project: ['read'],
apiKey: [], // No API key access
},
},
});

Custom resources and actions are merged with the defaults (organization, member, invitation, billing, ac) and (create, read, update, delete, cancel).

Permission Check Patterns

Pattern 1: Resource-Based Checks

Check if the user can perform specific actions:

import { getRequestHeaders } from '@tanstack/react-start/server';
import { auth } from '@kit/better-auth';
const { success: canInvite } = await auth.api.hasPermission({
headers: getRequestHeaders(),
body: { permissions: { invitation: ['create'] } },
});
const { success: canDelete } = await auth.api.hasPermission({
headers: getRequestHeaders(),
body: { permissions: { organization: ['delete'] } },
});

Pattern 2: Role Hierarchy Checks

Check if a user can manage another member:

import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
const canManage = canTargetRole(currentUser.role, targetMember.role, false, ROLE_HIERARCHY);

Import Reference

NeedImport
Permission checks@kit/better-auth (auth.api.hasPermission)
Role hierarchy functions@kit/rbac (canTargetRole, getRoleHierarchy)
Resources and actions constants@kit/rbac (RESOURCES, ACTIONS)
Role hierarchy@kit/rbac (ROLE_HIERARCHY)
Config factory@kit/rbac (defineRBACConfig)
Server action middleware@kit/action-middleware (withFeaturePermission, withMinRole)
Authenticated action factory@kit/action-middleware (authAction)
Organization action factory@kit/action-middleware (organizationAction)

Common Use Cases

Conditional UI Based on Permissions

Resolve the permission flags in the route loader, then read them in the component:

import { createFileRoute } from '@tanstack/react-router';
import {
fetchCanUpdateOrganization,
fetchCanDeleteOrganization,
} from '#/lib/auth/account.functions';
export const Route = createFileRoute('/_authenticated/settings/organization')({
loader: async () => {
const [canUpdate, canDelete] = await Promise.all([
fetchCanUpdateOrganization(),
fetchCanDeleteOrganization(),
]);
return { canUpdate, canDelete };
},
component: OrganizationSettings,
});
function OrganizationSettings() {
const { canUpdate, canDelete } = Route.useLoaderData();
return (
<div>
{canUpdate && <OrganizationSettingsForm />}
{canDelete && <DangerZone />}
</div>
);
}

fetchCanUpdateOrganization ships in apps/web/src/lib/auth/account.functions.ts. Each helper is a createServerFn({ method: 'GET' }) wrapper around an auth.api.hasPermission(...) check; add your own (e.g. fetchCanDeleteOrganization) following the same pattern.

Filter Roles in Dropdowns

Filter which roles a user can assign based on their own role level:

import { getAllDefaultRoles, canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
export function RoleSelector({ currentUserRole }) {
const allRoles = getAllDefaultRoles();
const availableRoles = allRoles.filter(role =>
canTargetRole(currentUserRole, role, true, ROLE_HIERARCHY)
);
return (
<Select>
{availableRoles.map(role => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</Select>
);
}

Validate Role Changes

Using middleware (recommended):

import { authAction, withFeaturePermission } from '@kit/action-middleware';
import { requireActiveOrganizationId } from '@kit/better-auth/context';
import { z } from 'zod';
export const updateMemberRoleAction = authAction()
.middleware([withFeaturePermission({ member: ['update'] })])
.validator(z.object({ memberId: z.string(), newRole: z.string() }))
.handler(async ({ data, context }) => {
const { memberId, newRole } = data;
// context.user is set; the user already has member:update permission
const organizationId = await requireActiveOrganizationId();
return auth.api.updateMemberRole({
body: { organizationId, memberId, role: newRole },
});
});

This mirrors the real updateMemberRoleAction in packages/organization/ui/src/lib/server/actions/members-server-actions.ts.

Using a manual hierarchy check inside the handler:

import { authAction } from '@kit/action-middleware';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
import { auth } from '@kit/better-auth';
export const updateMemberRoleAction = authAction()
.validator(z.object({ memberId: z.string(), newRole: z.string() }))
.handler(async ({ data }) => {
const { role: currentRole } = await auth.api.getActiveMemberRole({
headers: getRequestHeaders(),
});
if (!canTargetRole(currentRole, data.newRole, true, ROLE_HIERARCHY)) {
throw new Error('Insufficient permissions');
}
return auth.api.updateMemberRole({
body: { memberId: data.memberId, role: data.newRole },
});
});