[GH-ISSUE #3923] Email OTP verification doesn't check email verification status before sending the email on user signup #9762

Closed
opened 2026-04-13 05:27:40 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @Fefedu973 on GitHub (Aug 11, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3923

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Use hook on user creation to modify the user email verification state
  2. A verification challenge is created and sent with further verification, it should first check if it is necessary

Current vs. Expected behavior

Before sending the email verification otp on user signup the email verification status should be checked to avoid sending un-necessary emails or writing un-necessary edge case handler in the config (see below), this can happen when we use a db hook on user creation to automatically validate their email in certain conditions

What version of Better Auth are you using?

1.3.4

System info

Not related

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { emailOTP, organization, admin, twoFactor } from "better-auth/plugins";
import { passkey } from "better-auth/plugins/passkey"
import { expo } from "@better-auth/expo";
import {
  polar,
  checkout,
  portal,
  usage,
  webhooks,
} from "@polar-sh/better-auth";
import { Polar } from "@polar-sh/sdk";
import { resend } from "./resend";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db";
import * as schema from "../db/schema/auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { reactConfirmEmail } from "./email/confirmEmail";
import { reactPasswordEmail } from "./email/passwordEmail";
import { reactInvitationEmail } from "./email/invitationEmail"

const polarClient = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  // server: "sandbox"
});

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "sqlite",

    schema: schema,
  }),
  appName: "Open Backtest",
  trustedOrigins: [process.env.CLIENT_URL || "", "my-better-t-app://"],

  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    },
    microsoft: {
      clientId: process.env.MICROSOFT_CLIENT_ID || "",
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
    },
  },

  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url, token }) => {
      // Send reset password email
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    sendOnSignIn: true,
    autoSignInAfterVerification: true,
  },

  account: {
    accountLinking: {
      enabled: true,
    },
  },

  user: {
    changeEmail: {
      enabled: true,
      sendChangeEmailVerification: async ({ user, newEmail, url, token }) => {
        // Send change email verification
      },
    },
    deleteUser: {
      enabled: true,
      sendDeleteAccountVerification: async ({ user, url, token }) => {
        // Send delete account verification
      },
    },
    additionalFields: {
      role: {
        type: "string",
        required: false,
        input: false,
        defaultValue: "regular",
        validator: {
          input: z.enum(["regular", "partner", "admin"]),
          output: z.enum(["regular", "partner", "admin"]),
        },
      },
      onboardingCompleted: {
        type: "boolean",
        required: false,
        input: false,
        defaultValue: "false",
      },
      appTheme: {
        type: "string",
        required: false,
        input: false,
        defaultValue: "dark",
        validator: {
          input: z.enum(["light", "dark"]),
          output: z.enum(["light", "dark"]),
        },
      },
      appLanguage: {
        type: "string",
        required: false,
        input: false,
        defaultValue: "en",
        validator: {
          input: z.enum(["en", "fr"]),
          output: z.enum(["en", "fr"]),
        },
      },
      // isGuestAccount: {
      //   type: "boolean",
      //   required: false,
      //   input: false,
      //   defaultValue: "false",
      // },
      // managedByOrganization: {
      //   type: "string",
      //   required: false,
      //   input: false,
      // },
    },
  },

  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.SERVER_URL,
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          // Check if this user is being created for an invitation
          // We can determine this by checking if their email has a pending invitation
          try {
            const pendingInvitations = await db.select()
              .from(schema.invitation)
              .where(eq(schema.invitation.email, user.email));

            if (pendingInvitations.length > 0) {
              // This user is being created for an invitation - bypass email verification
              return {
                data: {
                  ...user,
                  emailVerified: true,
                },
              };
            }
          } catch (error) {
            console.error("Error checking for invitations:", error);
          }

          return { data: user };
        },
      },
    },
  },
  plugins: [
    expo(),
    emailOTP({
      overrideDefaultEmailVerification: true,
      sendVerificationOnSignUp: false,
      async sendVerificationOTP({ email, otp, type }) {
        const user = await db.query.user.findFirst({
          where: eq(schema.user.email, email),
        });

        if (!user) {
          throw new Error("User not found");
        }

        const currentYear = new Date().getFullYear();

        if (type === "email-verification") {
          if (user.emailVerified) {
            // User is already verified (from invitation flow) - complete verification to clean up DB
            try {
              await auth.api.verifyEmailOTP({
                body: {
                  email: email,
                  otp: otp,
                }
              });
              
              console.log(`Email verification completed for ${email} - already verified via invitation`);
              return;
            } catch (error) {
              throw new Error(`Error completing email verification for ${email}`);
              // Continue with normal flow if verification completion fails
            }
          }

          await resend.emails.send({
            from: "Open Backtest <onboarding@resend.dev>",
            to: email,
            subject: "Vérifiez votre adresse e-mail",
            react: reactConfirmEmail({
              user,
              code: otp,
              currentYear,
            }),
          });
        } else if (type === "sign-in") {
          return;
        } else if (type === "forget-password") {
          await resend.emails.send({
            from: "Open Backtest <onboarding@resend.dev>",
            to: email,
            subject: "Réinitialisez votre mot de passe",
            react: reactPasswordEmail({
              user,
              code: otp,
              currentYear,
            }),
          });
        }
      },
    }),
    organization({
      allowUserToCreateOrganization: async (user) => {
        return user.role === "partner";
      },
      organizationLimit: 1,
      async sendInvitationEmail(data) {
        console.log("Sending invitation email...");
        console.log(data);
        const inviteLink = `${process.env.NEXT_PUBLIC_WEB_URL || process.env.CLIENT_URL}/auth/accept-invitation?token=${data.id}`;
        const currentYear = new Date().getFullYear();
        await resend.emails.send({
          from: "Open Backtest <onboarding@resend.dev>",
          to: data.email,
          subject: "Invitation à rejoindre une organisation",
          react: reactInvitationEmail({
            invitedByUsername: data.inviter.user.name,
            invitedByEmail: data.inviter.user.email,
            organization: data.organization.name,
            inviteLink,
            currentYear
          }),
        });
      },
      organizationCreation: {
        beforeCreate: async ({ organization, user }, request) => {
          return {
            data: {
              ...organization,
              metadata: {
                ...organization.metadata,
                createdByPartner: user.role === "partner"
              }
            }
          };
        },
        afterCreate: async ({ organization, member, user }, request) => {
          console.log(`Organization ${organization.name} created by ${user.name} (${user.role})`);
        }
      }
    }),
    admin(),
    twoFactor(),
    passkey(),
    polar({
      client: polarClient,
      createCustomerOnSignUp: true,
      use: [
        checkout({
          products: [
            {
              productId: "9b1f75e8-3ac9-4166-9338-58ab2268e314",
              slug: "OpenBacktest-Pro",
            },
            {
              productId: "1bad310b-8dd1-498a-aa76-dae1d0b41dc0",
              slug: "OpenBacktest-Pro-Partner",
            },
          ],
          successUrl: "/success?checkout_id={CHECKOUT_ID}",
          authenticatedUsersOnly: true,
        }),
        portal(),
        usage(),
        webhooks({
          secret: process.env.POLAR_WEBHOOK_SECRET || "",
          // Add your webhook handlers here
        }),
      ]
    })
  ],
});

export type Session = typeof auth.$Infer.Session.session;
export type User = typeof auth.$Infer.Session.user;

Additional context

No response

Originally created by @Fefedu973 on GitHub (Aug 11, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3923 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Use hook on user creation to modify the user email verification state 2. A verification challenge is created and sent with further verification, it should first check if it is necessary ### Current vs. Expected behavior Before sending the email verification otp on user signup the email verification status should be checked to avoid sending un-necessary emails or writing un-necessary edge case handler in the config (see below), this can happen when we use a db hook on user creation to automatically validate their email in certain conditions ### What version of Better Auth are you using? 1.3.4 ### System info ```bash Not related ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { emailOTP, organization, admin, twoFactor } from "better-auth/plugins"; import { passkey } from "better-auth/plugins/passkey" import { expo } from "@better-auth/expo"; import { polar, checkout, portal, usage, webhooks, } from "@polar-sh/better-auth"; import { Polar } from "@polar-sh/sdk"; import { resend } from "./resend"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "../db"; import * as schema from "../db/schema/auth"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { reactConfirmEmail } from "./email/confirmEmail"; import { reactPasswordEmail } from "./email/passwordEmail"; import { reactInvitationEmail } from "./email/invitationEmail" const polarClient = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN, // server: "sandbox" }); export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite", schema: schema, }), appName: "Open Backtest", trustedOrigins: [process.env.CLIENT_URL || "", "my-better-t-app://"], socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", }, microsoft: { clientId: process.env.MICROSOFT_CLIENT_ID || "", clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "", }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url, token }) => { // Send reset password email }, }, emailVerification: { sendOnSignUp: true, sendOnSignIn: true, autoSignInAfterVerification: true, }, account: { accountLinking: { enabled: true, }, }, user: { changeEmail: { enabled: true, sendChangeEmailVerification: async ({ user, newEmail, url, token }) => { // Send change email verification }, }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url, token }) => { // Send delete account verification }, }, additionalFields: { role: { type: "string", required: false, input: false, defaultValue: "regular", validator: { input: z.enum(["regular", "partner", "admin"]), output: z.enum(["regular", "partner", "admin"]), }, }, onboardingCompleted: { type: "boolean", required: false, input: false, defaultValue: "false", }, appTheme: { type: "string", required: false, input: false, defaultValue: "dark", validator: { input: z.enum(["light", "dark"]), output: z.enum(["light", "dark"]), }, }, appLanguage: { type: "string", required: false, input: false, defaultValue: "en", validator: { input: z.enum(["en", "fr"]), output: z.enum(["en", "fr"]), }, }, // isGuestAccount: { // type: "boolean", // required: false, // input: false, // defaultValue: "false", // }, // managedByOrganization: { // type: "string", // required: false, // input: false, // }, }, }, secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.SERVER_URL, databaseHooks: { user: { create: { before: async (user, ctx) => { // Check if this user is being created for an invitation // We can determine this by checking if their email has a pending invitation try { const pendingInvitations = await db.select() .from(schema.invitation) .where(eq(schema.invitation.email, user.email)); if (pendingInvitations.length > 0) { // This user is being created for an invitation - bypass email verification return { data: { ...user, emailVerified: true, }, }; } } catch (error) { console.error("Error checking for invitations:", error); } return { data: user }; }, }, }, }, plugins: [ expo(), emailOTP({ overrideDefaultEmailVerification: true, sendVerificationOnSignUp: false, async sendVerificationOTP({ email, otp, type }) { const user = await db.query.user.findFirst({ where: eq(schema.user.email, email), }); if (!user) { throw new Error("User not found"); } const currentYear = new Date().getFullYear(); if (type === "email-verification") { if (user.emailVerified) { // User is already verified (from invitation flow) - complete verification to clean up DB try { await auth.api.verifyEmailOTP({ body: { email: email, otp: otp, } }); console.log(`Email verification completed for ${email} - already verified via invitation`); return; } catch (error) { throw new Error(`Error completing email verification for ${email}`); // Continue with normal flow if verification completion fails } } await resend.emails.send({ from: "Open Backtest <onboarding@resend.dev>", to: email, subject: "Vérifiez votre adresse e-mail", react: reactConfirmEmail({ user, code: otp, currentYear, }), }); } else if (type === "sign-in") { return; } else if (type === "forget-password") { await resend.emails.send({ from: "Open Backtest <onboarding@resend.dev>", to: email, subject: "Réinitialisez votre mot de passe", react: reactPasswordEmail({ user, code: otp, currentYear, }), }); } }, }), organization({ allowUserToCreateOrganization: async (user) => { return user.role === "partner"; }, organizationLimit: 1, async sendInvitationEmail(data) { console.log("Sending invitation email..."); console.log(data); const inviteLink = `${process.env.NEXT_PUBLIC_WEB_URL || process.env.CLIENT_URL}/auth/accept-invitation?token=${data.id}`; const currentYear = new Date().getFullYear(); await resend.emails.send({ from: "Open Backtest <onboarding@resend.dev>", to: data.email, subject: "Invitation à rejoindre une organisation", react: reactInvitationEmail({ invitedByUsername: data.inviter.user.name, invitedByEmail: data.inviter.user.email, organization: data.organization.name, inviteLink, currentYear }), }); }, organizationCreation: { beforeCreate: async ({ organization, user }, request) => { return { data: { ...organization, metadata: { ...organization.metadata, createdByPartner: user.role === "partner" } } }; }, afterCreate: async ({ organization, member, user }, request) => { console.log(`Organization ${organization.name} created by ${user.name} (${user.role})`); } } }), admin(), twoFactor(), passkey(), polar({ client: polarClient, createCustomerOnSignUp: true, use: [ checkout({ products: [ { productId: "9b1f75e8-3ac9-4166-9338-58ab2268e314", slug: "OpenBacktest-Pro", }, { productId: "1bad310b-8dd1-498a-aa76-dae1d0b41dc0", slug: "OpenBacktest-Pro-Partner", }, ], successUrl: "/success?checkout_id={CHECKOUT_ID}", authenticatedUsersOnly: true, }), portal(), usage(), webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET || "", // Add your webhook handlers here }), ] }) ], }); export type Session = typeof auth.$Infer.Session.session; export type User = typeof auth.$Infer.Session.user; ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-13 05:27:40 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Aug 11, 2025):

You're correct: the emailOTP plugin does not check if the user's email is already verified before creating and sending a verification OTP on signup. The OTP challenge is always created and stored, even if your user creation hook sets emailVerified to true. This means that even if you skip sending the email in your custom sendVerificationOTP function, the challenge still exists in the database, which can lead to unnecessary verification flows or edge cases you have to handle manually plugin code reference.

There isn't a built-in way to prevent OTP creation for already-verified users in the current plugin. The common workaround is what you're already doing: manually check user.emailVerified in sendVerificationOTP and skip sending the email if it's true, but this doesn't stop the challenge from being created see discussion.

If you want more control, you could consider removing overrideDefaultEmailVerification and using the core emailVerification.sendVerificationEmail config, which gives you the full user object and lets you decide whether to send the verification email at all see config example. However, this still won't prevent the OTP challenge from being created if you're using the emailOTP plugin for verification.

This is a known limitation and has been discussed by other users as well see related issue. For now, handling this in your custom logic is the best available approach.

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:3174057750 --> @dosubot[bot] commented on GitHub (Aug 11, 2025): <!-- Answer --> You're correct: the emailOTP plugin does not check if the user's email is already verified before creating and sending a verification OTP on signup. The OTP challenge is always created and stored, even if your user creation hook sets emailVerified to true. This means that even if you skip sending the email in your custom sendVerificationOTP function, the challenge still exists in the database, which can lead to unnecessary verification flows or edge cases you have to handle manually [plugin code reference](https://github.com/better-auth/better-auth/blob/f281c92b9940fe6e81e05c07d24b73f2c2f8bfdd/packages/better-auth/src/plugins/email-otp/index.ts). There isn't a built-in way to prevent OTP creation for already-verified users in the current plugin. The common workaround is what you're already doing: manually check user.emailVerified in sendVerificationOTP and skip sending the email if it's true, but this doesn't stop the challenge from being created [see discussion](https://github.com/better-auth/better-auth/issues/3761). If you want more control, you could consider removing overrideDefaultEmailVerification and using the core emailVerification.sendVerificationEmail config, which gives you the full user object and lets you decide whether to send the verification email at all [see config example](https://github.com/better-auth/better-auth/issues/3570). However, this still won't prevent the OTP challenge from being created if you're using the emailOTP plugin for verification. This is a known limitation and has been discussed by other users as well [see related issue](https://github.com/better-auth/better-auth/issues/2160). For now, handling this in your custom logic is the best available approach. <!-- 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/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d1fd9bb5-4574-4d1b-b7d9-bce2b967571e?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/3923)
Author
Owner

@frectonz commented on GitHub (Aug 11, 2025):

By overriding the emailVerfied field you have modified the co-variants better auth expects, so this not really a bug within the better auth implementation. I recommend modifying the emailVerification.sendVerificationEmail function to check if the emailVeridied field is set to true and early returning.

<!-- gh-comment-id:3175844363 --> @frectonz commented on GitHub (Aug 11, 2025): By overriding the `emailVerfied` field you have modified the co-variants better auth expects, so this not really a bug within the better auth implementation. I recommend modifying the `emailVerification.sendVerificationEmail` function to check if the `emailVeridied` field is set to `true` and early returning.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9762