Better Enrollment

Security

What is blocked, why, and how tokens are stored.

What is blocked in invite-only mode

While a private invite is pending:

AttemptBlocked byResponse
POST /sign-up/emailBetter Auth disableSignUpEMAIL_PASSWORD_SIGN_UP_DISABLED
POST /sign-in/emailNo credential account exists yetINVALID_EMAIL_OR_PASSWORD
POST /request-password-resetPlugin before-hookSilent success, nothing sent, no oracle
OAuth sign-up as a new userProvider disableSignUpRedirect with ?error=signup_disabled
OAuth linking to the pre-created userrequireLocalEmailVerified plus an account.create.before hookAccount not linked

After acceptance the user is verified, so OAuth linking works normally.

Why the password-reset hook exists

A private invite pre-creates an inert user, which locks the email. Without the hook, whoever controls the mailbox could set a password through the reset flow and skip the invite entirely, and outsiders could probe the endpoint to learn who has been invited. The hook returns Better Auth's own generic success response, byte-identical to the unknown-email case, and sends nothing.

The email lock

An address stays locked while a pending invite row exists for it, even past expiresAt; re-inviting returns CONFLICT. The lock is global for app and org-create invites but per-organization for org-join, so the same person can hold pending invites to several orgs, and an org inviter cannot probe for pending invites outside their own org. invite.delete removes a pending or cancelled invite plus its still-inert pre-created user, which frees the address. Accepted invites are permanent audit records and cannot be deleted.

Revoking is softer than deleting: the pre-created user stays behind, so both the password-reset hook and the OAuth-linking hook keep blocking for any non-accepted invite (pending or cancelled), not just pending ones.

No account oracles

When a signed-out visitor tries to accept a public invite with an email that already has an account, the response is the same SIGN_IN_REQUIRED action the activation flow uses, so the endpoint never confirms whether an account exists. The same guard covers activation invites called through /invite/accept directly: an invite issued to an already-established account can only merge inside that account's own session, never set its password from the token. The invite token proves mailbox control; changing or joining an existing account requires proof of account control, which is a session.

Tokens

Tokens are generateRandomString(32) from Better Auth's crypto module, roughly 190 bits of entropy, stored as SHA-256 base64url and looked up by hash. A database leak exposes no usable links.

Reporting security issues

If you discover a vulnerability, report it privately to talat@octopi.ai instead of opening a public issue. Include steps to reproduce where possible.

Last updated on

On this page