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,
    expiresAt
  }) {},
  async sendPublicInvitation({
    role,
    kind,
    mode,
    url,
    token,
    inviterName,
    inviterEmail,
    organizationName,
    maxUses,
    expiresAt
  }) {}
});

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,
    onOrgMemberAdded,
    onSeatLimitReached,
    onOrgDisabled,
    onOrgEnabled,
    onOrgDeleted
  }
});

Lifecycle hooks

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

Last updated on

On this page