[GH-ISSUE #5017] User enumeration possible through OTP email #27462

Closed
opened 2026-04-17 18:30:02 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @vitormanfredini on GitHub (Oct 1, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5017

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

/api/auth/email-otp/send-verification-otp route responds with USER_NOT_FOUND when I pass an email that doesn't exist when sign up is disabled.

Is that expected behaviour for some use case that I'm not seeing?

My setup is a NestJS application.
Relevant packages:
"@thallesp/nestjs-better-auth": "2.0.0",
"better-auth": "1.3.23",

Current vs. Expected behavior

Current behaviour:

/api/auth/email-otp/send-verification-otp responds that the user does not exist:

{
  "code": "USER_NOT_FOUND",
  "message": "User not found"
}

Code that does this:
19f39cb400/packages/better-auth/src/plugins/email-otp/index.ts (L227)

Expected behaviour:

Respond with { "success": true } regardless if the user exists or not. So no one knows which users exist in the database.

What version of Better Auth are you using?

1.3.23

System info

The command ran for a while and didn't output anything.

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth';
import { emailOTP } from 'better-auth/plugins';
import { createBetterAuthConnection } from './db-connection-factory';
import { EmailProvider } from 'src/providers/email';

export const emailProvider = new EmailProvider();

export const auth = betterAuth({
  database: createBetterAuthConnection(process.env.NODE_ENV || 'development'),
  baseURL: process.env.BETTER_AUTH_URL,
  secret: process.env.BETTER_AUTH_SECRET,
  plugins: [
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        if (type === 'sign-in') {
          await emailProvider.send({
            to: email,
            html: 'OTP CODE IS ' + otp,
            text: 'OTP CODE IS ' + otp,
            name: 'Testing',
            subject: 'OTP CODE'
          });
        } else if (type === 'email-verification') {
          // Send the OTP for email verification
        } else {
          // Send the OTP for password reset
        }
      },
      disableSignUp: true
    })
  ]
});

Additional context

No one can sign up to the application, we manage user creation programatically in other parts of the code. That's why sign up is disabled.

Originally created by @vitormanfredini on GitHub (Oct 1, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5017 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce `/api/auth/email-otp/send-verification-otp` route responds with `USER_NOT_FOUND` when I pass an email that doesn't exist when sign up is disabled. Is that expected behaviour for some use case that I'm not seeing? My setup is a NestJS application. Relevant packages: "@thallesp/nestjs-better-auth": "2.0.0", "better-auth": "1.3.23", ### Current vs. Expected behavior **Current behaviour:** `/api/auth/email-otp/send-verification-otp` responds that the user does not exist: ``` { "code": "USER_NOT_FOUND", "message": "User not found" } ``` Code that does this: https://github.com/better-auth/better-auth/blob/19f39cb400d1b7afe925528bb58bccb8c3f077c3/packages/better-auth/src/plugins/email-otp/index.ts#L227 **Expected behaviour:** Respond with `{ "success": true }` regardless if the user exists or not. So no one knows which users exist in the database. ### What version of Better Auth are you using? 1.3.23 ### System info ```bash The command ran for a while and didn't output anything. ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth'; import { emailOTP } from 'better-auth/plugins'; import { createBetterAuthConnection } from './db-connection-factory'; import { EmailProvider } from 'src/providers/email'; export const emailProvider = new EmailProvider(); export const auth = betterAuth({ database: createBetterAuthConnection(process.env.NODE_ENV || 'development'), baseURL: process.env.BETTER_AUTH_URL, secret: process.env.BETTER_AUTH_SECRET, plugins: [ emailOTP({ async sendVerificationOTP({ email, otp, type }) { if (type === 'sign-in') { await emailProvider.send({ to: email, html: 'OTP CODE IS ' + otp, text: 'OTP CODE IS ' + otp, name: 'Testing', subject: 'OTP CODE' }); } else if (type === 'email-verification') { // Send the OTP for email verification } else { // Send the OTP for password reset } }, disableSignUp: true }) ] }); ``` ### Additional context No one can sign up to the application, we manage user creation programatically in other parts of the code. That's why sign up is disabled.
GiteaMirror added the locked label 2026-04-17 18:30:02 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Oct 1, 2025):

Hi @vitormanfredini! I'm Dosu and I’m helping the better-auth team.

The current behavior of returning USER_NOT_FOUND when sign-up is disabled and the email doesn't exist is intentional in Better Auth v1.3.23, but it does expose a user enumeration risk. The code checks for user existence and throws this error if the user is not found and sign-up is disabled, with no alternative path to mask the response or configuration to override it in the email OTP plugin options [source].

Best practice is to always return a generic success response (like { "success": true }) to prevent attackers from discovering which emails are registered. The maintainers are aware of user enumeration concerns in similar flows, as shown by an open PR to fix this in the password reset endpoint by always returning a generic message [source].

If you want to avoid user enumeration, you would need to modify the plugin code to always return a generic success response for this endpoint, regardless of whether the user exists, since there is currently no built-in config or workaround for this in the released version.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3356516514 --> @dosubot[bot] commented on GitHub (Oct 1, 2025): <!-- Greeting --> Hi @vitormanfredini! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The current behavior of returning `USER_NOT_FOUND` when sign-up is disabled and the email doesn't exist is intentional in Better Auth v1.3.23, but it does expose a user enumeration risk. The code checks for user existence and throws this error if the user is not found and sign-up is disabled, with no alternative path to mask the response or configuration to override it in the email OTP plugin options [[source]](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/email-otp/index.ts). Best practice is to always return a generic success response (like `{ "success": true }`) to prevent attackers from discovering which emails are registered. The maintainers are aware of user enumeration concerns in similar flows, as shown by an open PR to fix this in the password reset endpoint by always returning a generic message [[source]](https://github.com/better-auth/better-auth/pull/5014). If you want to avoid user enumeration, you would need to modify the plugin code to always return a generic success response for this endpoint, regardless of whether the user exists, since there is currently no built-in config or workaround for this in the released version. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/f9217fff-d012-47ce-a5ea-6c2a8805f5b6?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/5017)
Author
Owner

@laneme commented on GitHub (Oct 21, 2025):

I think enumeration protection should be default or at least be configurable with these type of flows.

<!-- gh-comment-id:3425369135 --> @laneme commented on GitHub (Oct 21, 2025): I think enumeration protection should be default or at least be configurable with these type of flows.
Author
Owner

@carsondarling commented on GitHub (Oct 28, 2025):

In case it's helpful, my (potentially naive) workaround for account enumeration while using magic links with sign up disabled looks like this:

hooks: {
  after: createAuthMiddleware(async (ctx) => {
    if (ctx.path === '/sign-in/magic-link') {
      // For magic links, prevent return of "USER_NOT_FOUND" error
      const returned = ctx.context.returned as any; // Better-auth returns this value as `unknown`
      if (returned?.body?.code === 'USER_NOT_FOUND') {
        // Mimic a successful response
        return ctx.json({ status: true });
      }
    }
  }),
},

I'll second that account enumeration protection should be the default across the board.

<!-- gh-comment-id:3458457288 --> @carsondarling commented on GitHub (Oct 28, 2025): In case it's helpful, my (potentially naive) workaround for account enumeration while using magic links with sign up disabled looks like this: ```jsx hooks: { after: createAuthMiddleware(async (ctx) => { if (ctx.path === '/sign-in/magic-link') { // For magic links, prevent return of "USER_NOT_FOUND" error const returned = ctx.context.returned as any; // Better-auth returns this value as `unknown` if (returned?.body?.code === 'USER_NOT_FOUND') { // Mimic a successful response return ctx.json({ status: true }); } } }), }, ``` I'll second that account enumeration protection should be the default across the board.
Author
Owner

@carsondarling commented on GitHub (Nov 18, 2025):

This issue appears to be addressed in #5050

<!-- gh-comment-id:3549025578 --> @carsondarling commented on GitHub (Nov 18, 2025): This issue appears to be addressed in #5050
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27462