diff --git a/docs/content/docs/authentication/email-password.mdx b/docs/content/docs/authentication/email-password.mdx
index 8522e2d07e..3c679a0492 100644
--- a/docs/content/docs/authentication/email-password.mdx
+++ b/docs/content/docs/authentication/email-password.mdx
@@ -168,12 +168,35 @@ If you enable require email verification, users must verify their email before t
This only works if you have sendVerificationEmail implemented and if the user
is trying to sign in with email and password.
+
+ When `requireEmailVerification` is enabled, signing up with an existing email returns a success response instead of an error to prevent user enumeration.
```ts title="auth.ts"
export const auth = betterAuth({
emailAndPassword: {
+ requireEmailVerification: true, // [!code highlight]
+ },
+});
+```
+
+You can use the `onExistingUserSignUp` callback to notify the existing user when someone tries to register with their email address:
+
+```ts title="auth.ts"
+import { betterAuth } from "better-auth";
+import { sendEmail } from "./email"; // your email sending function
+
+export const auth = betterAuth({
+ emailAndPassword: {
+ enabled: true,
requireEmailVerification: true,
+ onExistingUserSignUp: async ({ user }, request) => {
+ void sendEmail({
+ to: user.email,
+ subject: "Sign-up attempt with your email",
+ text: "Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.",
+ });
+ },
},
});
```
@@ -410,6 +433,30 @@ export const auth = betterAuth({
"A callback function that is triggered when a user's password is changed successfully.",
type: "function",
},
+ onExistingUserSignUp: {
+ description:
+ "A callback triggered when someone signs up with an already-registered email. Only called when enumeration protection is active (requireEmailVerification: true or autoSignIn: false).",
+ type: "function",
+ default: "undefined",
+ },
+ autoSignIn: {
+ description:
+ "Automatically sign in the user after sign up. When set to false, the sign-up response returns a success response and enables enumeration protection.",
+ type: "boolean",
+ default: "true",
+ },
+ requireEmailVerification: {
+ description:
+ "Require users to verify their email before they can sign in. When enabled, the sign-up response returns a success response and enables enumeration protection.",
+ type: "boolean",
+ default: "false",
+ },
+ revokeSessionsOnPasswordReset: {
+ description:
+ "Whether to revoke all other sessions when resetting password.",
+ type: "boolean",
+ default: "false",
+ },
resetPasswordTokenExpiresIn: {
description:
"Number of seconds the reset password token is valid for.",
diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx
index 4fb2b7ea65..2a48d797f7 100644
--- a/docs/content/docs/reference/options.mdx
+++ b/docs/content/docs/reference/options.mdx
@@ -244,7 +244,10 @@ export const auth = betterAuth({
- `maxPasswordLength`: Maximum password length (default: `128`)
- `autoSignIn`: Automatically sign in the user after sign up
- `sendResetPassword`: Function to send reset password email
+- `onPasswordReset`: Callback triggered when a user's password is changed successfully
+- `revokeSessionsOnPasswordReset`: Revoke all other sessions when resetting password (default: `false`)
- `resetPasswordTokenExpiresIn`: Number of seconds the reset password token is valid for (default: `3600` seconds)
+- `onExistingUserSignUp`: Callback triggered when someone signs up with an already-registered email. Only called when `requireEmailVerification` is `true` or `autoSignIn` is `false` (default: `undefined`).
- `password`: Custom password hashing and verification functions
## `socialProviders`
diff --git a/packages/better-auth/src/api/routes/sign-up.test.ts b/packages/better-auth/src/api/routes/sign-up.test.ts
index 8e5b4f814d..60e2b31048 100644
--- a/packages/better-auth/src/api/routes/sign-up.test.ts
+++ b/packages/better-auth/src/api/routes/sign-up.test.ts
@@ -199,6 +199,178 @@ describe("sign-up with custom fields", async () => {
});
});
+/**
+ * @see https://github.com/better-auth/better-auth/issues/7972
+ */
+describe("sign-up user enumeration protection", async () => {
+ it("should return success for existing email when email verification is required", async () => {
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ const body = {
+ email: "existing-email@test.com",
+ password: "password123",
+ name: "Existing User",
+ };
+
+ await auth.api.signUpEmail({ body });
+
+ const duplicatedSignUp = await auth.api.signUpEmail({ body });
+
+ expect(duplicatedSignUp.token).toBeNull();
+ expect(duplicatedSignUp.user.email).toBe(body.email);
+ });
+
+ it("should call onExistingUserSignUp when requireEmailVerification is true", async () => {
+ const onExistingUserSignUp = vi.fn();
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ onExistingUserSignUp,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ const body = {
+ email: "callback-rev@test.com",
+ password: "password123",
+ name: "Callback User",
+ };
+
+ await auth.api.signUpEmail({ body });
+ expect(onExistingUserSignUp).not.toHaveBeenCalled();
+
+ await auth.api.signUpEmail({ body });
+ expect(onExistingUserSignUp).toHaveBeenCalledTimes(1);
+ expect(onExistingUserSignUp).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: expect.objectContaining({ email: body.email }),
+ }),
+ undefined,
+ );
+ });
+
+ it("should call onExistingUserSignUp when autoSignIn is false", async () => {
+ const onExistingUserSignUp = vi.fn();
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ autoSignIn: false,
+ onExistingUserSignUp,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ const body = {
+ email: "callback-autosignin@test.com",
+ password: "password123",
+ name: "Callback AutoSignIn",
+ };
+
+ await auth.api.signUpEmail({ body });
+ await auth.api.signUpEmail({ body });
+
+ expect(onExistingUserSignUp).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not call onExistingUserSignUp when enumeration protection is inactive", async () => {
+ const onExistingUserSignUp = vi.fn();
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ onExistingUserSignUp,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ const body = {
+ email: "callback-noenum@test.com",
+ password: "password123",
+ name: "No Enum",
+ };
+
+ await auth.api.signUpEmail({ body });
+ await expect(auth.api.signUpEmail({ body })).rejects.toThrow();
+
+ expect(onExistingUserSignUp).not.toHaveBeenCalled();
+ });
+
+ it("should not call onExistingUserSignUp for new user sign-ups", async () => {
+ const onExistingUserSignUp = vi.fn();
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ onExistingUserSignUp,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ await auth.api.signUpEmail({
+ body: {
+ email: "brand-new-user@test.com",
+ password: "password123",
+ name: "Brand New",
+ },
+ });
+
+ expect(onExistingUserSignUp).not.toHaveBeenCalled();
+ });
+
+ it("should return success for existing email when autoSignIn is disabled", async () => {
+ const { auth } = await getTestInstance(
+ {
+ emailAndPassword: {
+ enabled: true,
+ autoSignIn: false,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ const body = {
+ email: "existing-auto-signin@test.com",
+ password: "password123",
+ name: "Existing User",
+ };
+
+ await auth.api.signUpEmail({ body });
+
+ const duplicatedSignUp = await auth.api.signUpEmail({ body });
+
+ expect(duplicatedSignUp.token).toBeNull();
+ expect(duplicatedSignUp.user.email).toBe(body.email);
+ });
+});
+
describe("sign-up CSRF protection", async () => {
const { auth } = await getTestInstance(
{
diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts
index a275fa8ee0..c807e09c32 100644
--- a/packages/better-auth/src/api/routes/sign-up.ts
+++ b/packages/better-auth/src/api/routes/sign-up.ts
@@ -3,6 +3,7 @@ import { createAuthEndpoint } from "@better-auth/core/api";
import { runWithTransaction } from "@better-auth/core/context";
import { isDevelopment } from "@better-auth/core/env";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
+import { generateId } from "@better-auth/core/utils/id";
import * as z from "zod";
import { setSessionCookie } from "../../cookies";
import { parseUserInput } from "../../db";
@@ -230,11 +231,50 @@ export const signUpEmail = () =>
BASE_ERROR_CODES.PASSWORD_TOO_LONG,
);
}
- const dbUser = await ctx.context.internalAdapter.findUserByEmail(email);
+ const shouldReturnGenericDuplicateResponse =
+ ctx.context.options.emailAndPassword.autoSignIn === false ||
+ ctx.context.options.emailAndPassword.requireEmailVerification;
+ const additionalUserFields = parseUserInput(
+ ctx.context.options,
+ rest,
+ "create",
+ );
+ const normalizedEmail = email.toLowerCase();
+ const dbUser =
+ await ctx.context.internalAdapter.findUserByEmail(normalizedEmail);
if (dbUser?.user) {
ctx.context.logger.info(
`Sign-up attempt for existing email: ${email}`,
);
+ if (shouldReturnGenericDuplicateResponse) {
+ /**
+ * Hash the password to reduce timing differences
+ * between existing and non-existing emails.
+ */
+ await ctx.context.password.hash(password);
+ if (ctx.context.options.emailAndPassword?.onExistingUserSignUp) {
+ await ctx.context.runInBackgroundOrAwait(
+ ctx.context.options.emailAndPassword.onExistingUserSignUp(
+ { user: dbUser.user },
+ ctx.request,
+ ),
+ );
+ }
+ const now = new Date();
+ return ctx.json({
+ token: null,
+ user: parseUserOutput(ctx.context.options, {
+ ...additionalUserFields,
+ id: ctx.context.generateId({ model: "user" }) || generateId(),
+ email: normalizedEmail,
+ name,
+ image,
+ emailVerified: false,
+ createdAt: now,
+ updatedAt: now,
+ }) as User,
+ });
+ }
throw APIError.from(
"UNPROCESSABLE_ENTITY",
BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL,
@@ -251,12 +291,11 @@ export const signUpEmail = () =>
const hash = await ctx.context.password.hash(password);
let createdUser: User;
try {
- const data = parseUserInput(ctx.context.options, rest, "create");
createdUser = await ctx.context.internalAdapter.createUser({
- email: email.toLowerCase(),
+ email: normalizedEmail,
name,
image,
- ...data,
+ ...additionalUserFields,
emailVerified: false,
});
if (!createdUser) {
@@ -319,10 +358,7 @@ export const signUpEmail = () =>
}
}
- if (
- ctx.context.options.emailAndPassword.autoSignIn === false ||
- ctx.context.options.emailAndPassword.requireEmailVerification
- ) {
+ if (shouldReturnGenericDuplicateResponse) {
return ctx.json({
token: null,
user: parseUserOutput(ctx.context.options, createdUser) as User<
diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts
index 1d061e2019..7f6b4746f2 100644
--- a/packages/core/src/types/init-options.ts
+++ b/packages/core/src/types/init-options.ts
@@ -689,6 +689,20 @@ export type BetterAuthOptions = {
* @default false
*/
revokeSessionsOnPasswordReset?: boolean;
+ /**
+ * A callback function that is triggered when a user tries to sign up
+ * with an email that already exists. Useful for notifying the existing user
+ * that someone attempted to register with their email.
+ *
+ * This is only called when `requireEmailVerification: true` or `autoSignIn: false`.
+ */
+ onExistingUserSignUp?: (
+ /**
+ * @param user the existing user from the database
+ */
+ data: { user: User },
+ request?: Request,
+ ) => Promise;
}
| undefined;
/**