Authorization Policies

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

Add custom business rules to control organization operations - limit org creation, restrict invitation domains, require subscriptions for certain actions, and more.

Authorization policies in the Next.js Prisma kit add custom business rules to Better Auth organization operations - creation, updates, deletion, invitations, and role changes. Each policy evaluates before the operation and returns allow() or deny(). If any registered policy denies, the operation fails with an APIError. Use policies for limits, domain restrictions, subscription requirements, and approval workflows.

Authorization policies are functions evaluated before organization operations (create, update, delete, invite, etc.). Each policy returns allow() or deny(). If any policy denies, the operation is rejected.

Use policies when: you need business logic beyond role-based permissions - limits, domain restrictions, subscription requirements, approval workflows.

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

By default, no policies are registered. The registries are empty, meaning all operations are allowed. You add policies to enforce your business rules.

RegistryBetter Auth HookWhen It Runs
organizationCreateRegistrybeforeCreateOrganizationUser creates a new organization
organizationUpdateRegistrybeforeUpdateOrganizationUser updates org name/slug/logo
organizationDeleteRegistrybeforeDeleteOrganizationUser deletes an organization
invitationCreateRegistrybeforeCreateInvitationUser sends a member invitation
invitationAcceptRegistrybeforeAcceptInvitationUser accepts an invitation
invitationCancelRegistrybeforeCancelInvitationUser cancels a pending invitation
memberRemoveRegistrybeforeRemoveMemberUser removes a member
roleUpdateRegistrybeforeUpdateMemberRoleUser changes a member's role

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
}

No organizationId - the organization doesn't exist yet.

OrganizationUpdateContext

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

OrganizationDeleteContext

interface OrganizationDeleteContext {
userId: string;
organizationId: string;
timestamp: string;
}

InvitationCreateContext

interface InvitationCreateContext {
userId: string; // User sending the invitation
organizationId: string;
inviteeEmail: string; // Email being invited
inviteeRole: string; // Role assigned to invitee
timestamp: string;
}

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;
}

InvitationCancelContext

interface InvitationCancelContext {
userId: string; // User canceling
invitation: {
id: string;
organizationId: string;
inviterId: string; // Who originally sent the invite
};
timestamp: string;
}

MemberRemoveContext

interface MemberRemoveContext {
userId: string; // User performing removal
organizationId: string;
targetUserId: string; // Member being removed
timestamp: string;
}

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;
}

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:
organizationCreateRegistry.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, member } from '@kit/database';
import { eq, sql } from 'drizzle-orm';
import type { OrganizationCreateContext } from '../types';
export const orgLimitPolicy = definePolicy<OrganizationCreateContext>({
id: 'organization-create.user-limit',
evaluate: async (context) => {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(member)
.where(eq(member.userId, context.userId));
if (result[0].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, subscription } from '@kit/database';
import { and, eq, inArray } from 'drizzle-orm';
import type { InvitationCreateContext } from '../types';
export const subscriptionRequiredPolicy = definePolicy<InvitationCreateContext>({
id: 'invitation-create.subscription-required',
evaluate: async (context) => {
const activeSub = await db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, context.organizationId),
inArray(subscription.status, ['active', 'trialing']),
),
)
.limit(1);
if (!activeSub.length) {
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.evaluate({
userId: 'user-with-2-orgs',
timestamp: new Date().toISOString(),
});
expect(result.allowed).toBe(true);
});
it('denies creation when at limit', async () => {
const result = await orgLimitPolicy.evaluate({
userId: 'user-with-5-orgs',
timestamp: new Date().toISOString(),
});
expect(result.allowed).toBe(false);
expect(result.metadata?.code).toBe('ORG_LIMIT_EXCEEDED');
});
});

Common Pitfalls

  • Forgetting to register policies: Defining a policy isn't enough - you must call registry.registerPolicy() to activate it.
  • Async issues in evaluate: The evaluate function must be async and return a Promise. Missing await on database calls causes silent failures.
  • Not handling errors: If your policy throws, the operation fails with a generic error. Wrap database calls in try/catch and return meaningful deny() messages.
  • Confusing policies with RLS: Policies control actions (can they do X?), RLS controls data access (can they see Y?). Use both together.
  • Over-restricting operations: Test policies thoroughly. Overly strict policies frustrate users and cause support tickets.

Frequently Asked Questions

Do policies run before or after RLS?
Policies run before the database operation. They evaluate in the Better Auth hook, which fires before any Supabase query. RLS then applies to the actual database operation.
Can I have multiple policies for the same operation?
Yes. All registered policies run. If any returns deny(), the operation fails. Use this to layer multiple business rules.
How do I debug policy failures?
Add logging inside your evaluate function. The deny() message is returned to the client, so include helpful error codes and remediation hints.
Can I access the database in policies?
Yes. Import your database client and run queries. Just ensure your policy is async and awaits all database calls.
What's the difference between policies and middleware?
Middleware runs on every request at the edge. Policies run only for specific Better Auth operations and have full context (userId, organizationId, etc.).

Next: Members Management →