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,
expiresAt
}) {},
async sendPublicInvitation({
role,
kind,
mode,
url,
token,
inviterName,
inviterEmail,
organizationName,
maxUses,
expiresAt
}) {}
});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,
onOrgMemberAdded,
onSeatLimitReached,
onOrgDisabled,
onOrgEnabled,
onOrgDeleted
}
});Lifecycle hooks
betterEnrollment({
onInviteCreated,
onInviteAccepted,
onInviteRevoked,
onInviteDeleted,
onInviteExpired,
onInvalidRole
});Last updated on