Options
Every option betterEnrollment accepts, with defaults.
Every option betterEnrollment accepts, grouped by concern. The values shown are the defaults.
Mode
betterEnrollment({
mode: "auto", // "auto" | "invite-only" | "open"
allowOpenSignup: false
});mode: "auto" inspects your config at startup and picks the right mode; a mixed config throws and asks you to set mode explicitly. allowOpenSignup: true downgrades the open-sign-up-path error in invite-only mode to a warning, making the guarantee per-email. See The two modes.
Delivery
Implementations are yours; the plugin never sends mail.
betterEnrollment({
async sendPrivateInvitation({
email,
name,
role,
kind,
mode,
url,
token,
inviterName,
inviterEmail,
organizationName,
inviteId,
organizationId,
organizationRole,
teamId,
expiresAt
}) {},
async sendPublicInvitation({
role,
kind,
mode,
url,
token,
inviterName,
inviterEmail,
organizationName,
inviteId,
organizationId,
organizationRole,
teamId,
maxUses,
expiresAt
}) {}
});Both functions run on create and on resend. inviteId, organizationId, organizationRole and teamId come straight from the invite row, so one template can word each kind of invite differently (a new organization's first owner versus a team member) without looking the invite up by its token. App and org-create invites pass null for the organization fields; teamId is set only when an org-join invite names a team. organizationName is the org's name for org-join invites and the preset name for org-create invites that set one, otherwise null.
Roles
betterEnrollment({
validRoles: ["user", "admin"], // omit to skip validation
fallbackRole: undefined, // used when an invited role was deleted
defaultRole: "user"
});Lifetime and usage
betterEnrollment({
expiresIn: 60 * 60 * 24 * 7, // private invites, in seconds
publicExpiresIn: 60 * 60 * 24 * 7 // public invites; null = never
});Security posture
betterEnrollment({
hashTokens: true,
autoVerifyPublicInviteEmail: false,
exposeEmailOnGet: false
});autoVerifyPublicInviteEmail and its trade-offs are covered in Verifying public-invite emails. By default invite.get returns a masked email such as a***@example.com; see The invite page.
Passwordless
For apps signing in with the magic-link plugin. Auto-detected; the options exist to force or extend it:
betterEnrollment({
passwordless: "auto", // true | false | "auto" (magic link present, no emailAndPassword)
passwordlessVerifyPaths: ["/magic-link/verify"] // paths the sign-in backstop guards
});Additional fields
Extra profile fields collected at redemption, declared like Better Auth's user.additionalFields:
betterEnrollment({
additionalFields: {
department: { type: "string" }, // required by default, collected at sign-up
referral: { type: "string", required: false },
seniority: { type: "number", validator: { input: z.number().min(0) } }, // zod works
plan: { type: "string", defaultValue: "free" }, // a default makes it optional
team: { type: "string", actions: ["SIGN_UP", "CONFIRM"] } // also on confirm
}
});- Steps: of the four next actions,
SIGN_INand the terminal state collect nothing.SIGN_UPandCONFIRMare forms, andactions(default["SIGN_UP"]) picks which of them collect the field. The list is exact, never additive:["CONFIRM"]means confirm only, so name both steps (["SIGN_UP", "CONFIRM"]) to collect a field on both forms. - Storage: each field becomes a nullable column on the user model; run
migrateorgenerateafter configuring, and do not also declare it underuser.additionalFields. Requiredness is enforced at redemption, not by the database. - Validation: a missing required field, wrong type, or failing validator rejects with
ADDITIONAL_FIELD_REQUIRED/ADDITIONAL_FIELD_INVALIDbefore the invite is consumed. - No clobbering: on
CONFIRMthe update targets an existing account, sodefaultValueis never applied there. - Rendering:
invite.getlists the step's fields inrequiredFields/optionalFields, with a{ type, required }map inadditionalFields; see The invite page.
Permissions
Two systems; the full picture, including per-action granularity and precedence, is on Permissions.
Using the admin plugin's access-control file (highest priority, recommended):
betterEnrollment({
ac, // the same access controller given to the admin plugin
roles, // the same roles record; roles need invite:<action> grants
permissionResource: "invite" // point at your own resource name if it differs
});Standalone, without an AC file:
betterEnrollment({
adminRoles: ["admin"],
adminUserIds: [], // always allowed, in both systems
canManageInvites: undefined // (user) => boolean, replaces adminRoles/adminUserIds
});Links
betterEnrollment({
buildInviteUrl: ({ token, type, mode }) => `https://app.example.com/invite?token=${token}`
});Organizations
Pass the same ac and roles objects you gave the organization plugin; the whole group is covered on Organizations.
betterEnrollment({
organization: {
ac,
roles, // the same objects given to the org plugin
canCreateOrgInvites: undefined, // (member, org) => boolean
allowOwnerInvites: false,
defaultOrganizationRole: "member",
orgCreateRole: "owner",
defaultSeatLimit: undefined,
resolveSeatLimit: undefined, // async (org) => number | null
revokeInvitesOnInviterBan: true,
beforeOrgCreate: undefined, // ({ invite, user, organization }) => extra org fields, or throw
onOrgMemberAdded,
onSeatLimitReached,
onOrgDisabled,
onOrgEnabled,
onOrgDeleted
}
});beforeOrgCreate runs when an org-create redemption is about to found its org. The fields it returns are stored on the new organization, and throwing an APIError refuses the redemption while the invite stays pending; see Stamping your own fields.
Lifecycle hooks
betterEnrollment({
onInviteCreated,
onInviteAccepted,
onInviteRevoked,
onInviteDeleted,
onInviteExpired,
onInvalidRole
});Last updated on