Email OTP plugin logic issue #1432

Closed
opened 2026-03-13 08:39:54 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @bytaesu on GitHub (Jun 28, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Description

  • in the email-otp plugin,
  • even when disableSignUp is enabled,
  • it quietly succeeds without returning an ApiError or triggering the email sending logic.

This makes it difficult to handle the flow properly on the client side.

Request

I’ve opened a pull request with a fix for this issue, along with a few other improvements:
https://github.com/better-auth/better-auth/pull/3081

Since email OTP is our primary authentication method, it would be greatly appreciated if this could be reviewed.

Thanks!

Current vs. Expected behavior

Current

  1. Even when disableSignUp is enabled, it cannot be handled on the client side.
  2. Some internal logic is returning undefined unexpectedly.
  3. Within the email sending logic, the forget-password and disableSignUp handling are somewhat strangely coupled together.

Expected behavior

The above issues should be resolved.

What version of Better Auth are you using?

1.2.10

Provide environment information

- OS: MacOS
- Browser: Chrome + Safari

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

Backend, Client

Auth config (if applicable)

/**
 * Better Auth instance
 */
export const auth = (env: CloudflareBindings) => {
  const sql = neon(env.DATABASE_URL);
  const db = drizzle(sql, { schema: { ...schema } });

  return betterAuth({
    appName: 'Example',
    basePath: '/api',
    baseURL: env.BETTER_AUTH_URL,
    secret: env.BETTER_AUTH_SECRET,
    database: drizzleAdapter(db, { provider: 'pg' }),

    plugins: [
      createEmailOtpPlugin(env),
    ],

    trustedOrigins: ['http://localhost:3000', 'https://example.com', 'https://sandbox.example.com'],

    socialProviders: {
      google: {
        enabled: true,
        clientId: env.GOOGLE_CLIENT_ID,
        clientSecret: env.GOOGLE_CLIENT_SECRET,
        prompt: 'select_account',
      },
    },

    account: {
      accountLinking: {
        enabled: true,
        allowDifferentEmails: false,
        trustedProviders: ['google'],
      },
    },

    onAPIError: {
      throw: false,
      errorURL: '/api/error',
    },

    rateLimit: {
      enabled: true,
      storage: 'database',
      modelName: 'rateLimit',
      window: 60,
      max: 60,
      customRules: {
        '/email-otp/send-verification-otp': {
          window: 30,
          max: 1,
        },
      },
    },

    advanced: {
      ipAddress: {
        disableIpTracking: false,
        ipAddressHeaders: ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for'],
      },
      cookiePrefix: 'example',
      crossSubDomainCookies: {
        enabled: true,
        domain: 'example.com',
      },
      defaultCookieAttributes: {
        sameSite: 'none',
        secure: true,
        partitioned: true,
      },
    },
  });
};





/**
 * Email OTP Plugin
 */
export const createEmailOtpPlugin = (env: CloudflareBindings) => {
  return emailOTP({
    sendVerificationOnSignUp: false,
    disableSignUp: true,
    otpLength: 6,
    allowedAttempts: 6,
    expiresIn: 10 * 60, // 10 min
    async sendVerificationOTP({ email, otp, type }, _request) {
      switch (type) {
        case 'sign-in': {
          try {
            const vars: EmailLoginTemplateVars = { otp: otp };
            const output = Mustache.render(EMAIL_LOGIN_TEMPLATE, vars);

            await sendSimpleEmail({
              env: env,
              to: [email],
              subject: `${otp} - Example Login Verification`,
              html: output,
            });
          } catch (err) {
            console.error('EMAIL_SEND_FAILED: ', {
              type,
              email,
              error: err,
            });
            throw new APIError(
              'BAD_GATEWAY',
              {
                code: 'EMAIL_SEND_FAILED',
                message: 'Failed to send email',
              },
              undefined,
              502,
            );
          }
          break;
        }
        case 'email-verification': {
          //
          // Not used
          //
          break;
        }
        case 'forget-password': {
          //
          // Not used
          //
          break;
        }
        default: {
          throw new APIError('INTERNAL_SERVER_ERROR', {
            message: `Unsupported OTP type: ${type as string}`,
          });
        }
      }
    },
  });
};

Additional context

No response

Originally created by @bytaesu on GitHub (Jun 28, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce **Description** - in the email-otp plugin, - even when `disableSignUp` is enabled, - it quietly succeeds without returning an ApiError or triggering the email sending logic. This makes it difficult to handle the flow properly on the client side. **Request** I’ve opened a pull request with a fix for this issue, along with a few other improvements: https://github.com/better-auth/better-auth/pull/3081 Since email OTP is our primary authentication method, it would be greatly appreciated if this could be reviewed. Thanks! ### Current vs. Expected behavior **Current** 1. Even when disableSignUp is enabled, it cannot be handled on the client side. 2. Some internal logic is returning undefined unexpectedly. 3. Within the email sending logic, the forget-password and disableSignUp handling are somewhat strangely coupled together. **Expected behavior** The above issues should be resolved. ### What version of Better Auth are you using? 1.2.10 ### Provide environment information ```bash - OS: MacOS - Browser: Chrome + Safari ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript /** * Better Auth instance */ export const auth = (env: CloudflareBindings) => { const sql = neon(env.DATABASE_URL); const db = drizzle(sql, { schema: { ...schema } }); return betterAuth({ appName: 'Example', basePath: '/api', baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: 'pg' }), plugins: [ createEmailOtpPlugin(env), ], trustedOrigins: ['http://localhost:3000', 'https://example.com', 'https://sandbox.example.com'], socialProviders: { google: { enabled: true, clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, prompt: 'select_account', }, }, account: { accountLinking: { enabled: true, allowDifferentEmails: false, trustedProviders: ['google'], }, }, onAPIError: { throw: false, errorURL: '/api/error', }, rateLimit: { enabled: true, storage: 'database', modelName: 'rateLimit', window: 60, max: 60, customRules: { '/email-otp/send-verification-otp': { window: 30, max: 1, }, }, }, advanced: { ipAddress: { disableIpTracking: false, ipAddressHeaders: ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for'], }, cookiePrefix: 'example', crossSubDomainCookies: { enabled: true, domain: 'example.com', }, defaultCookieAttributes: { sameSite: 'none', secure: true, partitioned: true, }, }, }); }; /** * Email OTP Plugin */ export const createEmailOtpPlugin = (env: CloudflareBindings) => { return emailOTP({ sendVerificationOnSignUp: false, disableSignUp: true, otpLength: 6, allowedAttempts: 6, expiresIn: 10 * 60, // 10 min async sendVerificationOTP({ email, otp, type }, _request) { switch (type) { case 'sign-in': { try { const vars: EmailLoginTemplateVars = { otp: otp }; const output = Mustache.render(EMAIL_LOGIN_TEMPLATE, vars); await sendSimpleEmail({ env: env, to: [email], subject: `${otp} - Example Login Verification`, html: output, }); } catch (err) { console.error('EMAIL_SEND_FAILED: ', { type, email, error: err, }); throw new APIError( 'BAD_GATEWAY', { code: 'EMAIL_SEND_FAILED', message: 'Failed to send email', }, undefined, 502, ); } break; } case 'email-verification': { // // Not used // break; } case 'forget-password': { // // Not used // break; } default: { throw new APIError('INTERNAL_SERVER_ERROR', { message: `Unsupported OTP type: ${type as string}`, }); } } }, }); }; ``` ### Additional context _No response_
GiteaMirror added the plugin label 2026-03-13 08:39:54 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1432