Authorization Policies

Add custom authorization rules for organization operations via Better Auth hooks.

Authorization policies let you add custom business rules that control organization operations. Policies are evaluated by Better Auth hooks before each operation executes.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│ Better Auth │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Organization Plugin (organizationHooks) │ │
│ │ │ │
│ │ beforeCreateOrganization ──┐ │ │
│ │ beforeUpdateOrganization │ │ │
│ │ beforeDeleteOrganization │ ┌────────────────────┐ │ │
│ │ beforeCreateInvitation ├───►│ Policy Runtimes │ │ │
│ │ beforeAcceptInvitation │ │ (evaluate all │ │ │
│ │ beforeCancelInvitation │ │ registered │ │ │
│ │ beforeRemoveMember │ │ policies) │ │ │
│ │ beforeUpdateMemberRole ────┘ └────────────────────┘ │ │
│ │ │ │ │
│ └────────────────────────────────────────────┼──────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ allow() → proceed│ │
│ │ deny() → reject │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

How It Works

  1. User triggers an operation — e.g., creates an invitation via the UI
  2. Better Auth receives the request — via its API routes
  3. Organization hook fires — e.g., beforeCreateInvitation
  4. Hook builds context — extracts userId, organizationId, etc. from the request
  5. Policy runtime evaluates — runs all registered policies for that operation
  6. Result determines outcome:
    • All policies return allow() → operation proceeds
    • Any policy returns deny() → operation is rejected with APIError

Package Structure

packages/organization/policies/src/
├── registry.ts # Singleton registries + runtimes (edit to register policies)
├── types.ts # Context type definitions
├── policies/
│ └── custom.ts # YOUR POLICIES (won't be modified by upstream updates)
└── index.ts # Package exports
packages/organization/hooks/src/
└── authorization-hooks.ts # Better Auth hook implementations

Available Registries

The kit ships with default policies already registered on most registries (see registry.ts). These enforce organization creation limits, subscription checks on deletion, role-targeting on invitations/removals/role changes, and seat limits. You add further policies to enforce additional business rules.

RegistryBetter Auth HookWhen It RunsDefault policies
beforeOrganizationCreateRegistrybeforeCreateOrganizationUser creates a new organizationorganizationCreationLimitPolicy
beforeOrganizationUpdateRegistrybeforeUpdateOrganizationUser updates org name/slug/logo(none)
beforeOrganizationDeleteRegistrybeforeDeleteOrganizationUser deletes an organizationorganizationDeleteSubscriptionPolicy
beforeInvitationCreateRegistrybeforeCreateInvitationUser sends a member invitationinvitationRoleTargetingPolicy, invitationSeatLimitPolicy
beforeInvitationAcceptRegistrybeforeAcceptInvitationUser accepts an invitationinvitationAcceptSeatLimitPolicy
beforeInvitationCancelRegistrybeforeCancelInvitationUser cancels a pending invitationinvitationCancelTargetingPolicy
beforeMemberAddRegistrybeforeAddMemberA member is added directlymemberAddSeatLimitPolicy
beforeMemberRemoveRegistrybeforeRemoveMemberUser removes a membermemberRemoveTargetingPolicy
beforeRoleUpdateRegistrybeforeUpdateMemberRoleUser changes a member's roleroleUpdateTargetingPolicy

Context Types

Each hook passes a typed context to policies. The context contains all information available at that point in the request.

OrganizationCreateContext

interface OrganizationCreateContext {
userId: string; // User attempting to create
timestamp: string; // ISO timestamp
// Max organizations a single user may own (read by the limit policy)
maxOrganizationsPerUser: number;
// Callback returning how many organizations the user currently owns
getOwnedOrganizationCount: () => Promise<number>;
}

No organizationId — the organization doesn't exist yet.

OrganizationUpdateContext

interface OrganizationUpdateContext {
userId: string;
organizationId: string;
update: {
name?: string;
slug?: string;
logo?: string | null;
};
timestamp: string;
}

OrganizationDeleteContext

interface OrganizationDeleteContext {
userId: string;
organizationId: string;
timestamp: string;
// Callback returning the org's subscriptions (injected to avoid cycles)
getSubscriptions: () => Promise<SubscriptionInfo[]>;
}

InvitationCreateContext

interface InvitationCreateContext {
userId: string; // User sending the invitation
organizationId: string;
inviteeEmail: string; // Email being invited
inviteeRole: string; // Role assigned to invitee
timestamp: string;
// Inviter's role in the org, or null if not a member (role-targeting policy)
actingRole: string | null;
// Merged default + custom role-hierarchy map for the org
roleHierarchy: Record<string, number>;
// Callback returning seat usage, or null when not seat-enforced
getSeatUsage: () => Promise<SeatUsage | null>;
// True when a pending+unexpired invite already exists for inviteeEmail
isInviteeAlreadyReserved: () => Promise<boolean>;
}

InvitationAcceptContext

interface InvitationAcceptContext {
userId: string; // User accepting
userEmail: string; // User's email
invitation: {
id: string;
email: string; // Email the invite was sent to
organizationId: string;
role: string;
expiresAt: Date;
};
timestamp: string;
// Callback returning seat usage, or null when not seat-enforced
getSeatUsage: () => Promise<SeatUsage | null>;
}

InvitationCancelContext

interface InvitationCancelContext {
userId: string; // User canceling
invitation: {
id: string;
organizationId: string;
inviterId: string; // Who originally sent the invite
};
// Cancelling user's role in the org, or null if not a member
actingRole: string | null;
// Inviter's current role, or null if no longer a member (targeting policy)
inviterRole: string | null;
// Merged default + custom role-hierarchy map for the org
roleHierarchy: Record<string, number>;
timestamp: string;
}

MemberRemoveContext

interface MemberRemoveContext {
userId: string; // User performing removal
organizationId: string;
targetUserId: string; // Member being removed
timestamp: string;
// Acting user's role in the org, or null if not a member
actingRole: string | null;
// Target member's current role, or null if not a member
targetCurrentRole: string | null;
// Merged default + custom role-hierarchy map for the org
roleHierarchy: Record<string, number>;
}

RoleUpdateContext

interface RoleUpdateContext {
userId: string; // User changing the role
organizationId: string;
targetUserId: string; // Member whose role is changing
newRole: string; // The new role
timestamp: string;
// Acting user's role in the org, or null if not a member
actingRole: string | null;
// Target member's current role, or null if not a member
targetCurrentRole: string | null;
// Merged default + custom role-hierarchy map for the org
roleHierarchy: Record<string, number>;
}

Adding Custom Policies

Step 1: Define Your Policy

Create or edit packages/organization/policies/src/policies/custom.ts:

import { definePolicy, allow, deny } from '@kit/policies';
import type { OrganizationCreateContext } from '../types';
export const orgCreationLimitPolicy = definePolicy<OrganizationCreateContext>({
id: 'organization-create.limit-per-user',
evaluate: async (context) => {
const count = await getUserOrgCount(context.userId);
if (count >= 3) {
return deny({
code: 'ORG_LIMIT_EXCEEDED',
message: 'Maximum 3 organizations allowed',
remediation: 'Delete an existing organization first',
});
}
return allow();
},
});

Step 2: Register Your Policy

Edit packages/organization/policies/src/registry.ts:

import { orgCreationLimitPolicy } from './policies/custom';
// Find the ORGANIZATION CREATE section and add:
beforeOrganizationCreateRegistry.registerPolicy(orgCreationLimitPolicy);

Policy Results

Use helpers from @kit/policies:

import { allow, deny } from '@kit/policies';
// Allow the operation to proceed
return allow();
// Block the operation
return deny({
code: 'CUSTOM_ERROR_CODE', // Stable code for programmatic handling
message: 'Human-readable error', // Shown to user
remediation: 'How to fix this', // Optional guidance
});

Error Handling

When a policy denies an operation, the hook throws a Better Auth APIError:

throw new APIError('FORBIDDEN', {
code: 'CUSTOM_ERROR_CODE',
message: 'Human-readable error',
});

The error propagates to the client and can be handled in your UI.

Example Policies

Limit Organizations Per User

import { definePolicy, allow, deny } from '@kit/policies';
import { db } from '@kit/database';
import type { OrganizationCreateContext } from '../types';
export const orgLimitPolicy = definePolicy<OrganizationCreateContext>({
id: 'organization-create.user-limit',
evaluate: async (context) => {
const count = await db.member.count({
where: { userId: context.userId },
});
if (count >= 5) {
return deny({
code: 'ORG_LIMIT_EXCEEDED',
message: 'You can only belong to 5 organizations',
});
}
return allow();
},
});

Email Domain Restriction for Invitations

import { definePolicy, allow, deny } from '@kit/policies';
import type { InvitationCreateContext } from '../types';
export const domainPolicy = definePolicy<InvitationCreateContext>({
id: 'invitation-create.domain-restriction',
evaluate: async (context) => {
const allowedDomains = ['company.com', 'subsidiary.com'];
const domain = context.inviteeEmail.split('@')[1];
if (!allowedDomains.includes(domain)) {
return deny({
code: 'DOMAIN_NOT_ALLOWED',
message: `Only ${allowedDomains.join(', ')} emails can be invited`,
});
}
return allow();
},
});

Prevent Self-Removal

import { definePolicy, allow, deny } from '@kit/policies';
import type { MemberRemoveContext } from '../types';
export const preventSelfRemovalPolicy = definePolicy<MemberRemoveContext>({
id: 'member-remove.prevent-self',
evaluate: async (context) => {
if (context.userId === context.targetUserId) {
return deny({
code: 'CANNOT_REMOVE_SELF',
message: 'You cannot remove yourself from the organization',
remediation: 'Ask another admin to remove you',
});
}
return allow();
},
});

Require Subscription for Invitations

import { definePolicy, allow, deny } from '@kit/policies';
import { db } from '@kit/database';
import type { InvitationCreateContext } from '../types';
export const subscriptionRequiredPolicy = definePolicy<InvitationCreateContext>({
id: 'invitation-create.subscription-required',
evaluate: async (context) => {
const activeSub = await db.subscription.findFirst({
where: {
referenceId: context.organizationId,
status: { in: ['active', 'trialing'] },
},
select: { id: true },
});
if (!activeSub) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'An active subscription is required to invite members',
remediation: 'Subscribe to a plan first',
});
}
return allow();
},
});

Seat Enforcement

Seat-based subscription limits are enforced via SeatEnforcementService, not policies. This is because seat enforcement has special logic for resend operations that policies can't easily handle.

See Seat Enforcement for details.

Testing Policies

Policies are pure functions that can be unit tested:

import { describe, it, expect } from 'vitest';
import { orgLimitPolicy } from './custom';
describe('orgLimitPolicy', () => {
it('allows creation when under limit', async () => {
const result = await orgLimitPolicy
.create({
userId: 'user-with-2-orgs',
timestamp: new Date().toISOString(),
maxOrganizationsPerUser: 5,
getOwnedOrganizationCount: async () => 2,
})
.evaluate();
expect(result.allowed).toBe(true);
});
it('denies creation when at limit', async () => {
const result = await orgLimitPolicy
.create({
userId: 'user-with-5-orgs',
timestamp: new Date().toISOString(),
maxOrganizationsPerUser: 5,
getOwnedOrganizationCount: async () => 5,
})
.evaluate();
expect(result.allowed).toBe(false);
expect(result.metadata?.code).toBe('ORG_LIMIT_EXCEEDED');
});
});