feat: limit enumeration on sign-up when email verification is required (#8091)

This commit is contained in:
Taesu
2026-02-22 08:51:08 +09:00
committed by GitHub
parent 6f545cad26
commit 3ec7d41a91
5 changed files with 280 additions and 8 deletions

View File

@@ -168,12 +168,35 @@ If you enable require email verification, users must verify their email before t
<Callout>
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.
</Callout>
```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.",

View File

@@ -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`

View File

@@ -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(
{

View File

@@ -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 = <O extends BetterAuthOptions>() =>
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<O["user"], O["plugins"]>,
});
}
throw APIError.from(
"UNPROCESSABLE_ENTITY",
BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL,
@@ -251,12 +291,11 @@ export const signUpEmail = <O extends BetterAuthOptions>() =>
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 = <O extends BetterAuthOptions>() =>
}
}
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<

View File

@@ -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<void>;
}
| undefined;
/**