[GH-ISSUE #7972] Security: sign-up/email leaks account existence via 422 (email enumeration) #28280

Closed
opened 2026-04-17 19:43:19 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @nphlp on GitHub (Feb 14, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7972

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Register a user with POST /api/auth/sign-up/email
  2. Register again with the same email

The server returns a 422 with:

{
    "code": "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
    "message": "User already exists. Use another email."
}

An attacker inspecting network responses can determine whether an email is already registered.

Current vs. Expected behavior

Current: sign-up with an existing email returns a 422 with an explicit error code, leaking account existence.

Expected: sign-up with an existing email should return a 200 with a response body indistinguishable from a real sign-up (same structure, similar timing). This is the approach already used by the reset-password endpoint, which returns { status: true } with timing simulation for non-existent emails.

This is an OWASP authentication best practice — the server should never reveal whether an account exists through differential error messages or response codes.

Workaround

Wrapping toNextJsHandler POST in a Next.js route handler proxy that intercepts 422 + USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL and returns a fake 200 with realistic user data built from the request body.

Additional context

  • Related: #5017, #7944 (same enumeration pattern on emailOtp with USER_NOT_FOUND)
Originally created by @nphlp on GitHub (Feb 14, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7972 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Register a user with `POST /api/auth/sign-up/email` 2. Register again with the same email The server returns a **422** with: ```json { "code": "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", "message": "User already exists. Use another email." } ``` An attacker inspecting network responses can determine whether an email is already registered. ### Current vs. Expected behavior **Current:** sign-up with an existing email returns a 422 with an explicit error code, leaking account existence. **Expected:** sign-up with an existing email should return a **200** with a response body indistinguishable from a real sign-up (same structure, similar timing). This is the approach already used by the reset-password endpoint, which returns `{ status: true }` with timing simulation for non-existent emails. This is an [OWASP authentication best practice](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-and-error-messages) — the server should never reveal whether an account exists through differential error messages or response codes. ### Workaround Wrapping `toNextJsHandler` POST in a Next.js route handler proxy that intercepts 422 + `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` and returns a fake 200 with realistic user data built from the request body. ### Additional context - Related: #5017, #7944 (same enumeration pattern on emailOtp with `USER_NOT_FOUND`)
GiteaMirror added the locked label 2026-04-17 19:43:19 -05:00
Author
Owner

@DanielvG-IT commented on GitHub (Feb 16, 2026):

Agree, this is a security issue, and I want to walk through my thinking: if we just flip this to a 200 OK then we're possibly going to leave users pretty confused.

User flow:

  1. User forgets they have an account and tries to "Sign Up" with a new password.
  2. We give them a 200 OK (to stay "secure").
  3. They immediately try to login with that new password and it fails because the DB still has their old one.
  4. Now they're stuck in a loop thinking the site is broken, and we might end up with some frustrated support tickets.

My thoughts

If we're going to fix the enumeration issue, the "Expected Behavior" shouldn't just be a silent success. I think it needs to be a more thoughtful flow. Especially if email verification is enabled and required for signin:

  • The API always returns a generic 200 ("Check your email").
  • The logic sends a verification link if they're new, OR a "You already have an account, please login or reset password" email if they exist.

This way, attackers get no info from the 422, but real users aren't left wondering why their "new" account doesn't work. I'd rather we solve the security issue without creating a frustrating user experience.

<!-- gh-comment-id:3908123363 --> @DanielvG-IT commented on GitHub (Feb 16, 2026): Agree, this is a security issue, and I want to walk through my thinking: if we just flip this to a 200 OK then we're possibly going to leave users pretty confused. ## User flow: 1. User forgets they have an account and tries to "Sign Up" with a new password. 2. We give them a 200 OK (to stay "secure"). 3. They immediately try to login with that new password and it fails because the DB still has their old one. 4. Now they're stuck in a loop thinking the site is broken, and we might end up with some frustrated support tickets. ## My thoughts If we're going to fix the enumeration issue, the "Expected Behavior" shouldn't just be a silent success. I think it needs to be a more thoughtful flow. Especially if email verification is enabled and required for signin: - The API always returns a generic 200 ("Check your email"). - The logic sends a verification link if they're new, OR a "You already have an account, please login or reset password" email if they exist. This way, attackers get no info from the 422, but real users aren't left wondering why their "new" account doesn't work. I'd rather we solve the security issue without creating a frustrating user experience.
Author
Owner

@nphlp commented on GitHub (Feb 16, 2026):

Effectively, for a user who already has an account but doesn't remember: they need to receive a specific email: "You already have an account, log in or reset your password". While the UI still shows a generic success message that doesn't reveal the account's existence.

I think this is a good approach.

Currently, I have a proxy in my Next.js route handler that intercepts the 422 USER_ALREADY_EXISTS and returns a fake 200 with synthetic user data. The client always shows "Check your email" regardless. I'm going to add a contextual email to the existing user inside this proxy.

Here's the workaround for anyone facing the same issue:

// app/api/auth/[...all]/route.ts
import { auth } from "@lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
import { nanoid } from "nanoid";

const { GET, POST: authPOST } = toNextJsHandler(auth);

async function POST(request: Request) {
    const clonedRequest = request.clone();
    const response = await authPOST(request);

    // Handle sign up errors
    if (response.status === 422 && request.url.includes("/sign-up/email")) {
        const error = await response.clone().json();

        // Handle "User already exists" case
        if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
            const { name, lastname, email } = await clonedRequest.json();
            const now = new Date().toISOString();

            sendEmail({
                to: email,
                subject: "Sign-up attempt",
                text: "You already have an account. Log in or reset your password.",
            });

            // Return perfectly iso valid user data sent by the client
            // /!\ Do not send user data from database
            return Response.json({
                token: null,
                user: {
                    name,
                    email,
                    emailVerified: false,
                    image: null,
                    createdAt: now,
                    updatedAt: now,
                    lastname,
                    id: nanoid(),
                },
            });
        }
    }

    return response;
}

export { GET, POST };

On the Better Auth side, a native option like emailAndPassword.onSignUpUserAlreadyExists could handle this without requiring app-level proxies — consistent with the existing onPasswordReset callback pattern:

export const auth = betterAuth({
    emailAndPassword: {
        enabled: true,
        onSignUpUserAlreadyExists: async ({ user }, request) => {
            sendEmail({
                to: user.email,
                subject: "Sign-up attempt",
                text: "You already have an account. Log in or reset your password.",
            });
        },
    },
});

I think this would be a great addition to Better Auth, as it addresses a common UX issue while maintaining security best practices. What do you think about this approach?

<!-- gh-comment-id:3909256679 --> @nphlp commented on GitHub (Feb 16, 2026): Effectively, for a user who already has an account but doesn't remember: they need to receive a specific email: _"You already have an account, log in or reset your password"_. While the UI still shows a generic success message that doesn't reveal the account's existence. I think this is a good approach. Currently, I have a proxy in my Next.js route handler that intercepts the `422 USER_ALREADY_EXISTS` and returns a fake `200` with synthetic user data. The client always shows "Check your email" regardless. I'm going to add a contextual email to the existing user inside this proxy. Here's the workaround for anyone facing the same issue: ```ts // app/api/auth/[...all]/route.ts import { auth } from "@lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; import { nanoid } from "nanoid"; const { GET, POST: authPOST } = toNextJsHandler(auth); async function POST(request: Request) { const clonedRequest = request.clone(); const response = await authPOST(request); // Handle sign up errors if (response.status === 422 && request.url.includes("/sign-up/email")) { const error = await response.clone().json(); // Handle "User already exists" case if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") { const { name, lastname, email } = await clonedRequest.json(); const now = new Date().toISOString(); sendEmail({ to: email, subject: "Sign-up attempt", text: "You already have an account. Log in or reset your password.", }); // Return perfectly iso valid user data sent by the client // /!\ Do not send user data from database return Response.json({ token: null, user: { name, email, emailVerified: false, image: null, createdAt: now, updatedAt: now, lastname, id: nanoid(), }, }); } } return response; } export { GET, POST }; ``` On the Better Auth side, a native option like `emailAndPassword.onSignUpUserAlreadyExists` could handle this without requiring app-level proxies — consistent with the existing `onPasswordReset` callback pattern: ```ts export const auth = betterAuth({ emailAndPassword: { enabled: true, onSignUpUserAlreadyExists: async ({ user }, request) => { sendEmail({ to: user.email, subject: "Sign-up attempt", text: "You already have an account. Log in or reset your password.", }); }, }, }); ``` I think this would be a great addition to Better Auth, as it addresses a common UX issue while maintaining security best practices. What do you think about this approach?
Author
Owner

@bytaesu commented on GitHub (Feb 21, 2026):

Hi, I'm checking this 🧐

<!-- gh-comment-id:3939007230 --> @bytaesu commented on GitHub (Feb 21, 2026): Hi, I'm checking this 🧐
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28280