Email-Optional User Accounts#
Better Auth's user schema treats email as technically optional in the database, but several core API routes and plugins assume it is always present — causing crashes or silent misbehavior when a user record has email = null.
How email-less accounts are created#
The phone-number plugin is the primary source of email-less users. When signUpOnVerification is configured, a new user is created on first OTP verification and must be given a caller-supplied temporary email via the required getTempEmail(phoneNumber): string callback . The actual user creation call populates email with whatever that callback returns .
Because getTempEmail is fully under application control, the email field will be whatever string the app provides — a real-looking placeholder, a UUID-based sentinel, or (if the callback returns an empty/invalid value) something that downstream code will reject. There is no framework-enforced contract preventing a null or empty email from reaching the database.
Phone-number sign-ups that do not use signUpOnVerification also produce records with no email, since the plugin never sets one .
Affected flows#
POST /change-email (core)#
The changeEmail endpoint assumes session.user.email is a non-null string. At line 717 it does:
if (newEmail === ctx.context.session.user.email)
and at line 757 it passes ctx.context.session.user.email to createEmailVerificationToken . Both operations call .toLowerCase() on the current email internally; if the stored email is null, this produces an uncaught TypeError: Cannot read properties of null (reading 'toLowerCase') — a 500 Internal Server Error rather than a clean 400.
This is tracked in issue #11507.
POST /email-otp/request-email-change (email-OTP plugin)#
The requestEmailChangeEmailOTP endpoint does the same at line 1074:
const email = ctx.context.session.user.email.toLowerCase();
If session.user.email is null, this throws immediately with the same null-dereference crash.
POST /email-otp/change-email (email-OTP plugin)#
The changeEmailEmailOTP endpoint repeats the pattern at line 1212:
const email = session.user.email.toLowerCase();
It uses email to construct the compound OTP identifier change-email-otp-{currentEmail}-{newEmail} and to look up the current user by email . Both fail if email is null.
POST /update-user (core)#
The updateUser endpoint explicitly blocks email updates via a BAD_REQUEST guard at line 102-107 . This means there is no safe built-in path for an email-less user to add their first email address.
Workarounds (current state)#
There is no official fix as of the tracked issue. Interim options:
- Use a deterministic sentinel email in
getTempEmail(e.g.,phone+{phoneNumber}@placeholder.invalid) soemailis nevernull, then replace it via a custom server-side adapter call once the user provides a real email. - Server-side direct DB update: bypass the API and update the user record in the database directly, then invalidate the session so it is re-hydrated with the new email.
- Guard at the callsite: before invoking
changeEmailor the email-OTP change-email flow, check whethersession.user.emailis null and surface a meaningful error to the client.
Key source files#
| File | Purpose |
|---|---|
packages/better-auth/src/api/routes/update-user.ts | changeEmail endpoint — crashes on null email at L717 and L757 |
packages/better-auth/src/plugins/email-otp/routes.ts | requestEmailChangeEmailOTP (L1074) and changeEmailEmailOTP (L1212) — same crash |
packages/better-auth/src/plugins/phone-number/routes.ts | Phone-number signup that creates users with caller-controlled email |
packages/better-auth/src/plugins/phone-number/types.ts | getTempEmail callback type declaration |
| GitHub issue #11507 | Bug report with repro and expected behavior |