Better Enrollment

Recipes

Short patterns for common setups.

buildInviteUrl: ({ token }) => `${process.env.APP_URL}/invite?token=${encodeURIComponent(token)}`,

Captcha on redemption endpoints

captcha({ endpoints: ["/invite/redeem", "/invite/accept", "/invite/activate"] });

Breached password checks for invited users

haveIBeenPwned({
  paths: ["/sign-up/email", "/reset-password", "/invite/redeem", "/invite/accept"]
});

Gating invited users with validateUserInfo

On Better Auth 1.7, user.validateUserInfo sees every user Better Enrollment creates: the pre-created account behind a private invite, and the account made when someone redeems a public invite. The plugin passes source.method === "invite", so you can apply a rule to invite sign-ups only, or tell them apart from your other sign-up paths:

betterAuth({
  user: {
    validateUserInfo: ({ user, source }) => {
      if (source.method !== "invite") return;
      if (!String(user.email).endsWith("@acme.com")) {
        return { error: "DOMAIN_NOT_ALLOWED", errorDescription: "Use your work email." };
      }
    }
  }
});

A private invite to a rejected address fails at /invite/create, so the inviter sees it straight away and nothing is stored. A rejected public redemption fails at redeem, and the use goes back to the invite. Both return your code with status 403.

Gating org creation by role

An org-create invite bypasses this gate by design; the invitation is itself the authorization:

organization({ allowUserToCreateOrganization: roleGate(["admin", "org-creator"]) });

Changing roles without losing invite-granted ones

Better Auth stores multiple roles as one comma-separated string, and the admin plugin's setRole replaces the whole field with whatever you send. Invite redemption grants roles by merging into that field, so a bare setRole({ role: "admin" }) from your admin UI silently strips anything an invite granted earlier (such as org-creator). Always compute the union first:

const roles = (user.role || "user")
  .split(",")
  .map((r) => r.trim())
  .filter(Boolean);

if (!roles.includes("admin")) {
  await authClient.admin.setRole({ userId: user.id, role: [...roles, "admin"] });
}

Demoting is the same in reverse: filter the role out and send what remains, falling back to your default role when the list would be empty.

Last updated on

On this page