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; /**