mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 07:18:56 -05:00
feat: limit enumeration on sign-up when email verification is required (#8091)
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user