Better Enrollment

Magic links

Passwordless invites for magic-link apps, from setup to the invite page.

Apps that sign in with Better Auth's magic-link plugin have no passwords. Better Enrollment detects that and switches redemption to passwordless: invitees accept without a password, and the plugin blocks the ways a magic link could sneak past a pending invite.

Setup

Register both plugins

Keep magic-link sign-up closed in an invite-only app; the invite is the only door.

auth.ts
import { magicLink } from "better-auth/plugins";
import { betterEnrollment } from "@octopi-ai/better-enrollment";

export const auth = betterAuth({
  plugins: [
    magicLink({
      disableSignUp: true,
      sendMagicLink: async ({ email, url }) => {
        await sendEmail(email, `Sign in: ${url}`);
      }
    }),
    betterEnrollment({
      sendPrivateInvitation: async ({ email, url }) => {
        await sendEmail(email, `You are invited: ${url}`);
      }
    })
  ]
});

That is all

Passwordless mode is on automatically when the magic-link plugin is registered and emailAndPassword is not enabled. No option needed; force it either way with passwordless: true | false.

What changes

With passwordsPasswordless
Accept bodypassword requiredno password (sending one fails with PASSWORD_NOT_AVAILABLE)
Account rowcredential account creatednone; sign-in is always by magic link
After a private acceptuser signs in with the new passwordsigned in immediately (signedIn: true, session cookie set)
After a public acceptuser signs in with the new passworduser signs in with their first magic link, which also verifies the email

A private invite can sign the accepter in directly because the emailed token already proved they own the mailbox. A shareable public link proves nothing, so verification waits for the first magic link.

Build the invite page

The same invite page pattern works unchanged; invite.get tells you everything:

app/invite/page.tsx
const info = await authClient.invite.get({ query: { token } });
// info.passwordless === true, info.requiredFields has no "password"

// 1. Render a form from info.requiredFields (name, email for public invites).
const res = await authClient.invite.accept({ token, name });

// 2. Route by what accept did:
if (res.data?.signedIn) {
  router.push("/dashboard"); // private invite: already signed in
} else {
  // public invite: one magic link signs them in and verifies the email
  await authClient.signIn.magicLink({
    email,
    callbackURL: "/dashboard"
  });
}

If you drive the form purely from requiredFields, passwordless needs no special casing; the password input simply never renders.

Mixed apps

With both emailAndPassword and magic link enabled, auto detection keeps the password flow: accept requires a password and creates a credential account, exactly as before. Set passwordless: true to accept without one anyway; accepters then sign in via magic link.

Built-in protection

Magic-link verify signs in any existing user, and a private invite pre-creates one, so without a guard the invitee could sign in and skip your invite flow (no name, no additional fields, no audit row). The plugin ships three guards, active only in invite-only mode:

  • A hook on /sign-in/magic-link silently skips sending for addresses held by a pre-created shell, returning the endpoint's normal success body: no dead link in the inbox, no invite oracle.
  • A hook on /magic-link/verify covers links already in flight: it rejects sign-ins for pre-created shells with INVITATION_REQUIRED, resolving the link's email without consuming the token and honoring the magic-link plugin's storeToken setting.
  • A session.create.before backstop covers verify paths the hook cannot resolve. Its path list is passwordlessVerifyPaths (default ["/magic-link/verify"]); extend it if you add other passwordless plugins.

Existing accounts are never blocked (activation invites sign in normally), ordinary sign-ins cost zero extra queries, and mode detection counts the magic-link sign-up path, so mode: "auto" and the invite-only interlock both see it.

Options

auth.ts
betterEnrollment({
  passwordless: "auto", // true | false | "auto" (default)
  passwordlessVerifyPaths: ["/magic-link/verify"] // paths the session backstop guards
});

Where to go next

Last updated on

On this page