Skip to content

Policy Engine

The policy engine is a chain-of-responsibility pattern. Each step is a pure function that receives a PolicyContext and returns a PolicyResult. If any step returns pass: false, the chain short-circuits and the request is rejected.

Executed after Better Auth creates the user account but before the response is returned. Controls whether the user is allowed to register for this specific application.

AllowedProviders → EmailDomainCheck → SignupPolicyGate
PolicyChecksPass condition
AllowedProvidersThe signup method (email, google) is in the application’s allowed_providers listProvider is allowed
EmailDomainCheckThe email domain is not blocked or outside the whitelistDomain passes filter
SignupPolicyGateThe application’s signup_policy settingDepends on policy type

Signup policy behaviors:

PolicyBehavior
openAnyone can register. Creates application_user with status=active.
invite_onlyRequires a valid invitation token. Without one, registration is rejected.
admin_approvalRegistration accepted but application_user status set to pending_approval. Admin must approve via the admin console.
auto_on_first_accessAutomatically creates application_user on first access. Used for SSO groups.

Executed after Better Auth verifies credentials and creates a session.

ApplicationUserStatus
PolicyChecksPass condition
ApplicationUserStatusThe user’s application_user.status for this applicationstatus is “active”

If the user is suspended or disabled, signin is rejected even with valid credentials. The user must contact an administrator.

Executed during JWT issuance.

ApplicationUserStatus → CustomClaimsInjector

The CustomClaimsInjector adds app_user information to the JWT payload, including the membership id, status, role, and profile.

Executed during OAuth authorization.

SSOGroupCheck → ConsentPolicy → ApplicationUserGate

The SSOGroupCheck prevents session reuse across different SSO groups.

interface PolicyContext {
applicationId: string;
application: Application;
authPolicy: ApplicationAuthPolicy;
userId?: string;
email?: string;
request?: { origin?: string; method?: string; url?: string };
}
interface PolicyResult {
pass: boolean;
reason?: { code: string; message: string; httpStatus: number };
injectClaims?: Record<string, unknown>;
}
type PolicyHandler = (ctx: PolicyContext, db: DB) => Promise<PolicyResult>;

The policy engine is integrated into the auth flow via the Auth Hook (auth-hooks.ts), which wraps Better Auth’s native handler:

Fastify Request
├── toStandardRequest(req) → standard Request object
├── auth.handler(standardReq) → Better Auth processes it
│ → Returns Response (success or failure)
├── If success:
│ → Parse response body
│ → Resolve application from origin header
│ → Load application auth_policy
│ → Evaluate policy chain
│ → If pass: create/update application_user
│ → If fail: undo Better Auth's work (delete user)
└── Write Better Auth Response back (with all Set-Cookie headers)