Changelog

What's new in Better Enrollment

Every release of the plugin, with the features, fixes, and breaking changes that shipped in it. Also published on GitHub releases.

Coexisting invites redeem in one go

Redemption now follows the account's live state, so an expired or stale invite to the same email can never strand a later one at sign-in, and the email lock is scoped to one invite per kind and organization.

Behavior changes

No API, response-shape, error-code, or database schema changes. Two documented rules change meaning:

  • redeem: For a private invite in invite-only mode, SIGN_UP versus SIGN_IN is now decided from the account's live state at redemption, not from which invite pre-created it. An inert pre-created shell (unverified, no credentials), whoever created it, renders the sign-up form and is claimed by the token being redeemed; an established account gets SIGN_IN / CONFIRM as before. So when an app invite expires and an org later sends an org-join invite to the same address, the invitee opens one link, fills one form, and joins the org, with no sign-in step over an account that has no password. Same in reverse, and across organizations. If two invites are live at once, whichever is redeemed first signs up; the other becomes a one-click activation, and roles merge in either order.
  • redeem: Once a token claims a shell it did not pre-create, the other invites that pointed at that shell are handed over: they become plain activation invites over the now-real account. Resending one later routes to SIGN_IN → CONFIRM and merges its role, and the magic-link guards stop treating the account as an invite shell.
  • create: The pending-email lock is now scoped to one invite per kind and organization. It still holds past expiresAt within a scope (resend is the release valve, as before), but invites in other scopes always coexist: an app admin can invite someone who holds an expired or live org-join elsewhere, and vice versa. app and org-create used to lock globally, which forced an app admin to touch an organization's invites to proceed. USER_ALREADY_EXISTS on app invites now fires only for an established account; an inert shell just skips pre-creation.
  • redeem: A private invite whose account has disappeared (the invite that pre-created it was deleted, swept by expiry cleanup, or its organization was deleted) now creates a fresh account on redemption instead of failing with PRE_CREATED_USER_MISSING. The token still proves the mailbox.

Enhancements

  • list: GET /invite/list accepts email and kind filters, so a UI can resolve EMAIL_ALREADY_INVITED to the exact row and offer a resend in place. Same visibility rules as every other filter: org members see their org only.

Fixes

  • org deletion: /invite/org/delete removed its org-join invites without cleaning up their pre-created shells, unlike every other deletion path. A deleted org's invited-but-not-yet-joined address stayed locked with nothing left to delete it through. Org deletion now clears inert shells the same way expiry cleanup does.

Migration notes

No schema or config changes, and no new error codes. Two things to check in your invite UI:

  1. EMAIL_ALREADY_INVITED now means "this exact invite already exists" (same kind, same org). Offer a resend for it rather than telling the user the address is taken elsewhere; look up the inviteId with invite.list filtered by email.
  2. Your invite page should already branch on nextAction; nothing changes in the payload. If it hard-coded "invite-only always means SIGN_UP", it now needs the SIGN_IN and CONFIRM branches for invitees who already hold an account.

Access-control files and passwordless magic links

One access-control file now drives invite permissions everywhere, and magic-link apps get passwordless redemption with built-in invite-only guards.

Breaking changes

  • org: A provided organization.roles record now fully replaces the built-in owner/admin defaults, mirroring the org plugin's own hasPermission. A role missing from your record, including owner, no longer holds invitation permissions implicitly. Apps that pass no roles record are unaffected: owner and admin keep their defaults.
  • modes: The magic-link plugin's sign-up path is now visible to mode detection. mode: "auto" with an open magic link alongside a closed email path now throws the mixed-config error at init, and explicit invite-only with an open magic link throws unless allowOpenSignup: true. Previously this path silently bypassed detection; set disableSignUp: true on magicLink() or set mode explicitly.

Enhancements

  • permissions: Pass the same access-control file you built for the admin plugin and one file drives permissions everywhere. New top-level ac and roles options mirror admin({ ac, roles }), checked per action (create, resend, list, cancel, delete, manage-orgs) with the adminUserIds bypass intact. New exports: inviteStatements (spread into your statement object), inviteAdminAc, inviteUserAc, and defaultInviteRoles (the fallback when only ac is given). The legacy adminRoles / adminUserIds / canManageInvites gate is unchanged and still applies when no AC is passed; when both are configured, the AC wins. See Permissions.

    export const statement = { ...defaultStatements, ...inviteStatements } as const;
    export const ac = createAccessControl(statement);
    export const admin = ac.newRole({ ...adminAc.statements, ...inviteAdminAc.statements });
    
    plugins: [adminPlugin({ ac, roles: { admin } }), betterEnrollment({ ac, roles: { admin } })];
  • permissions: New permissionResource option (default "invite") points the check at a resource your AC file already names, for example enrollment: ["create"], without renaming anything. Action verbs stay canonical.

  • magic-link: Passwordless redemption for magic-link apps. Auto-detected (passwordless: "auto", on when the magic-link plugin is registered and emailAndPassword is off): accept works without a password and never creates a credential account. Private accepters are signed in directly (signedIn: true in the response, session cookie set) since the emailed token already proved the mailbox; public accepters stay signed out and unverified until their first magic link. A password sent to a passwordless app fails with the new PASSWORD_NOT_AVAILABLE (400) instead of being silently dropped. See Magic links.

  • magic-link: Three built-in guards keep invite-only closed: /sign-in/magic-link silently skips sending for pre-created invite shells (same no-oracle pattern as the password-reset guard), /magic-link/verify rejects in-flight links for shells with INVITATION_REQUIRED, and a session.create.before backstop guards the paths in the new passwordlessVerifyPaths option (default ["/magic-link/verify"]). Existing accounts always sign in normally; ordinary sign-ins cost zero extra queries.

  • api: GET /invite/get now returns passwordless and omits "password" from requiredFields on passwordless apps, so pages rendering from requiredFields adapt by themselves. Accept and redeem responses gain a signedIn boolean, and /invite/accept no longer requires password at the schema level (the flow still enforces it unless passwordless).

Fixes

  • accept: An activation invite held by an established passwordless account (verified, zero account rows) now returns SIGN_IN_REQUIRED instead of letting a direct /invite/accept call overwrite the profile and mint a session from token possession.
  • redeem: A failed passwordless accept now removes the session it minted before rolling the invite back to pending, so a pending invite can never leave a live session behind.

Migration notes

  1. If you pass a partial organization.roles record, re-add every role that should manage org invites (including owner) with invitation: ["create", "cancel"] grants.
  2. Run npx @better-auth/cli migrate (or generate): invite.preCreatedUserId gained an index for the new sign-in guards.
  3. Magic-link apps: nothing to configure; verify your invite page renders from requiredFields and branches on signedIn after accept.

Additional fields at redemption

Collect custom profile fields on the invite page, declared per step, and name is now required on sign-up redemptions.

Breaking changes

  • redeem: name is now required to redeem a sign-up invitation. invite.redeem and invite.accept reject sign-up redemptions that omit it with the new NAME_REQUIRED (400) error; the accept flow exists to populate the invitee's profile, and a nameless profile defeats it. Creating an invite still does not require a name, and activation flows (open mode, or invites held by existing accounts) are unaffected.
  • api: requiredFields from GET /invite/get now includes "name" for every SIGN_UP response. Pages that render inputs directly from requiredFields pick this up automatically; tests asserting the exact array need updating.

Enhancements

  • options: New additionalFields option collects extra profile fields at redemption, mirroring Better Auth's user.additionalFields: type (string, number, boolean, date), required (default true), defaultValue, an optional standard-schema validator (a plain zod schema works), and actions. Fields become nullable columns on the user model (run migrate or generate; do not duplicate them under user.additionalFields), are validated before the invite use is consumed (ADDITIONAL_FIELD_REQUIRED / ADDITIONAL_FIELD_INVALID, so a rejected submission never burns a use), and are stored on the created or activated user. See Options.

    betterEnrollment({
      additionalFields: {
        department: { type: "string" },
        referral: { type: "string", required: false },
        seniority: { type: "number", validator: { input: z.number().min(0) } },
        team: { type: "string", actions: ["SIGN_UP", "CONFIRM"] }
      }
    });
  • redeem: Per-step collection via actions. Of the four next actions, SIGN_IN and the terminal state render no fields; SIGN_UP and CONFIRM are forms and can carry additional fields. The actions list (default ["SIGN_UP"]) is exact, never additive: ["CONFIRM"] means confirm only; name both to cover both forms. On CONFIRM the values are written to the existing user in the same update as the role merge, and defaultValue is never applied there, so an absent field can never overwrite data the user already has.

  • api: GET /invite/get now describes the current step's form: a new optionalFields array and a fields map ({ [name]: { type, required } }) alongside requiredFields, computed per step: the sign-up built-ins plus that step's extra fields for SIGN_UP, the confirm-step fields for CONFIRM, and all three empty for SIGN_IN and terminal states.

Migration notes

  1. Add a name input to your invite page for SIGN_UP states (rendering from requiredFields does it by itself) and pass name in invite.redeem / invite.accept calls.
  2. If you adopt additionalFields, run npx @better-auth/cli migrate (or generate) to create the columns. Pages that render from requiredFields / optionalFields pick the fields up automatically, CONFIRM included.

System invites and universal resend

Headless invite creation for cron jobs and webhooks, resend for every invite kind, and a safer redemption flow that no longer signs users in.

Breaking changes

  • redeem: Redemption no longer creates a session, for security reasons. invite.redeem and invite.accept create the user and credential account, then return without signing the user in; sign them in through your application's designated auth flow, for example authClient.signIn.email({ email, password }). With requireEmailVerification: true, an unverified public-invite accepter can no longer enter until they verify.
  • redeem: The token field was removed from the accept and redeem response. The success shape is now { action: "ACCEPTED", user, organization? }, matching the activation flow.
  • redeem: Redemption no longer sets the session's active organization in either flow. Selecting the active org after an org-join or org-create invite is your app's decision via the org plugin's setActive.
  • hooks: onInviteCreated now receives admin: User | null; it is null for invites created through the server-only endpoint.

Enhancements

  • api: New resendInvite endpoint (authClient.invite.resend({ inviteId })), universal across every invite kind and delivery type. Rotates the token and expiry on the same invite row, so the previous link is invalidated on success while the invitee, roles, org bindings, remaining uses, and audit history are preserved. Revives expired pending invites (re-checking seat limits for org-join). Adds the onInviteResent hook.
  • api: New server-only createSystemInvite endpoint for headless invite creation from cron jobs, webhooks, and system integrations. No session required; every invariant still runs, only the permission gates are skipped. Attribution resolves explicit inviter, then appName, then "System", with createdByUserId stored as null. Never mounted as an HTTP route.

Fixes

  • verification: Public-invite redemptions now send Better Auth's verification email when emailVerification.sendOnSignUp (or requireEmailVerification) is configured. Previously it was never sent because redemption bypasses the sign-up route where sendOnSignUp runs.

First public release

Invitations as the front door of your app. Invite-only mode, private and public invites, organization onboarding, and a full audit trail.

Better Enrollment is a plugin for Better Auth that makes invitations the front door of your app: run fully invite-only, hand out shareable invite links, or use invites as role and organization grants in an open app.

Highlights

  • Invite-only mode: closes every sign-up route (email, OAuth, password reset) so people get in only through invitations.
  • Open mode: invitations become role and organization grants for self-serve sign-ups.
  • Private invites: email-bound, single use, verified on accept.
  • Public invites: shareable links with use caps, expiry, and revocation.
  • Organization onboarding: one link joins an org, or lets the invitee found their own, with per-org seat limits, pending-invite reservations, and subscription hooks.
  • One redemption page: a single ?token= page and one redeem call handle every invite kind in both modes.
  • Security first: SHA-256 hashed tokens (~190 bits of entropy), atomic race-safe redemption, and no email or account oracles on any endpoint.
  • Audit trail: append-only record of who invited whom and who redeemed what.
  • Adapter-agnostic: works with any Better Auth database adapter, no extra infrastructure.

Published to npm as @octopi-ai/better-enrollment with provenance via trusted publishing. Start with the Quick Start.