Better Enrollment

Options

Every option betterEnrollment accepts, with defaults.

Every option betterEnrollment accepts, grouped by concern. The values shown are the defaults.

Mode

auth.ts
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.

auth.ts
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

auth.ts
betterEnrollment({
  validRoles: ["user", "admin"], // omit to skip validation
  fallbackRole: undefined, // used when an invited role was deleted
  defaultRole: "user"
});

Lifetime and usage

auth.ts
betterEnrollment({
  expiresIn: 60 * 60 * 24 * 7, // private invites, in seconds
  publicExpiresIn: 60 * 60 * 24 * 7 // public invites; null = never
});

Security posture

auth.ts
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:

auth.ts
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:

auth.ts
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_IN and the terminal state collect nothing. SIGN_UP and CONFIRM are forms, and actions (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 migrate or generate after configuring, and do not also declare it under user.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_INVALID before the invite is consumed.
  • No clobbering: on CONFIRM the update targets an existing account, so defaultValue is never applied there.
  • Rendering: invite.get lists the step's fields in requiredFields / optionalFields, with a { type, required } map in additionalFields; 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):

auth.ts
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:

auth.ts
betterEnrollment({
  adminRoles: ["admin"],
  adminUserIds: [], // always allowed, in both systems
  canManageInvites: undefined // (user) => boolean, replaces adminRoles/adminUserIds
});
auth.ts
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.

auth.ts
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

auth.ts
betterEnrollment({
  onInviteCreated,
  onInviteAccepted,
  onInviteRevoked,
  onInviteDeleted,
  onInviteExpired,
  onInvalidRole
});

Last updated on

On this page