[GH-ISSUE #7348] APIError thrown in emailOTP.sendVerificationOTP no longer bubbles up to client #28112

Open
opened 2026-04-17 19:30:49 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @juanbill on GitHub (Jan 14, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7348

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

The following code snippet works perfectly fine in better-auth 1.4.6

plugins: [
        emailOTP({
            async sendVerificationOTP({ email, otp}) {
                const user = await db.user.findUnique({
                    where: { email },
                    select: { name: true, emailVerified: true },
                });

                if (!user) {
                    throw new APIError("BAD_REQUEST", {
                        message: "USER_NOT_FOUND", // This gets converted to error.code in the client. 
                    });
                }

Since better-auth 1.4.7 an the APIError thrown is Caught by Task Runner; logs "Failed to run background task". And on the client side, it returns a success message, so the following result.error no longer runs.

 const result = await authClient.emailOtp.sendVerificationOtp({
                    email: _email,
                    type: "sign-in",
                });
                if (result.error) {
                    // if (result.error.code === authClient.$ERROR_CODES.USER_NOT_FOUND) {
                    if (result.error.code === "USER_NOT_FOUND") {
                        onRegisterCallback(_email);
                    }

Is this is the intended OTP behaviour?
If so, what is the new workaround?

Current vs. Expected behavior

Current behaviour returns a success message with no errors.

Expected behaviour should return the USER_NOT_FOUND error response

What version of Better Auth are you using?

1.4.7 - 1.4.12

System info

2026-01-14T01:10:18.547Z ERROR [Better Auth]: Failed to run background task: [Error [APIError]: USER_NOT_FOUND] with OTP workflow

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

Backend, Client

Auth config (if applicable)

export const auth = betterAuth({
    secret: env.BETTER_AUTH_SECRET,
    database: prismaAdapter(db, {
        provider: "postgresql",
    }),
    user: {
        additionalFields: {
            username: {
                type: "string",
                required: true,
            },
            emailHash: {
                type: "string",
                required: true,
            },
            role: {
                type: "string",
                required: true,
            },
            appUrl: {
                type: "string",
                required: false,
            },
            appName: {
                type: "string",
                required: false,
            },
        },
    },
    plugins: [
        emailOTP({
            async sendVerificationOTP({ email, otp}) {
                const user = await db.user.findUnique({
                    where: { email },
                    select: { name: true, emailVerified: true },
                });

                if (!user) {
                    throw new APIError("BAD_REQUEST", {
                        message: "USER_NOT_FOUND", // This gets converted to error.code in the client. 
                    });
                }
            },
            otpLength: 6,
            expiresIn: env.OTP_EXPIRATION,
        }),
        nextCookies(), // Must be last plugin - handles cookie setting in Next.js
    ],
    session: {
        expiresIn: 60 * 60 * 24 * 30, // 30 days
        updateAge: 60 * 60 * 24, // Update session every 24 hours
    },
    trustedOrigins: [env.PUBLIC_HUB_URL],
    advanced: {
        useSecureCookies: process.env.NODE_ENV === "production",
        cookiePrefix: "better-auth",
        database: {
            generateId: () => crypto.randomUUID(),
        },
    },
});

Additional context

Currently remained in better-auth 1.4.6

Originally created by @juanbill on GitHub (Jan 14, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7348 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce The following code snippet works perfectly fine in better-auth 1.4.6 ``` plugins: [ emailOTP({ async sendVerificationOTP({ email, otp}) { const user = await db.user.findUnique({ where: { email }, select: { name: true, emailVerified: true }, }); if (!user) { throw new APIError("BAD_REQUEST", { message: "USER_NOT_FOUND", // This gets converted to error.code in the client. }); } ``` Since better-auth 1.4.7 an the APIError thrown is Caught by Task Runner; logs "Failed to run background task". And on the client side, it returns a success message, so the following result.error no longer runs. ``` const result = await authClient.emailOtp.sendVerificationOtp({ email: _email, type: "sign-in", }); if (result.error) { // if (result.error.code === authClient.$ERROR_CODES.USER_NOT_FOUND) { if (result.error.code === "USER_NOT_FOUND") { onRegisterCallback(_email); } ``` Is this is the intended OTP behaviour? If so, what is the new workaround? ### Current vs. Expected behavior Current behaviour returns a success message with no errors. Expected behaviour should return the USER_NOT_FOUND error response ### What version of Better Auth are you using? 1.4.7 - 1.4.12 ### System info ```bash 2026-01-14T01:10:18.547Z ERROR [Better Auth]: Failed to run background task: [Error [APIError]: USER_NOT_FOUND] with OTP workflow ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript export const auth = betterAuth({ secret: env.BETTER_AUTH_SECRET, database: prismaAdapter(db, { provider: "postgresql", }), user: { additionalFields: { username: { type: "string", required: true, }, emailHash: { type: "string", required: true, }, role: { type: "string", required: true, }, appUrl: { type: "string", required: false, }, appName: { type: "string", required: false, }, }, }, plugins: [ emailOTP({ async sendVerificationOTP({ email, otp}) { const user = await db.user.findUnique({ where: { email }, select: { name: true, emailVerified: true }, }); if (!user) { throw new APIError("BAD_REQUEST", { message: "USER_NOT_FOUND", // This gets converted to error.code in the client. }); } }, otpLength: 6, expiresIn: env.OTP_EXPIRATION, }), nextCookies(), // Must be last plugin - handles cookie setting in Next.js ], session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // Update session every 24 hours }, trustedOrigins: [env.PUBLIC_HUB_URL], advanced: { useSecureCookies: process.env.NODE_ENV === "production", cookiePrefix: "better-auth", database: { generateId: () => crypto.randomUUID(), }, }, }); ``` ### Additional context Currently remained in better-auth 1.4.6
GiteaMirror added the credentialsbug labels 2026-04-17 19:30:49 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 14, 2026):

It sounds like you're encountering a change in behavior with the sendVerificationOTP function in Better Auth. In versions 1.4.7 and onwards, an APIError thrown in the email verification process may not bubble up as expected, leading to a success message being returned even when the user is not found.

From the information available, it appears that there's been an update to how errors are handled in the email OTP plugin. The expected behavior of returning an error for USER_NOT_FOUND may not be occurring because the logic has changed in how background tasks handle exceptions. This could result in errors being caught and not properly reported back to the client.

A PR aimed at improving error handling suggests that the new error handling logic may need adjustments to ensure that the APIError will appropriately inform the client when a user is not found.

As for your next steps, consider the following options:

  1. Review the current plugin implementation and see if any other handling logic can be adjusted to catch these errors.
  2. If necessary, tag @bekacru or @ping__ for assistance since they can provide more specific insights or potential workarounds for this recent change.

What specific changes would you be willing to make in your implementation to adapt to this new behavior?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3747410990 --> @better-auth-agent[bot] commented on GitHub (Jan 14, 2026): It sounds like you're encountering a change in behavior with the `sendVerificationOTP` function in Better Auth. In versions 1.4.7 and onwards, an APIError thrown in the email verification process may not bubble up as expected, leading to a success message being returned even when the user is not found. From the information available, it appears that there's been an update to how errors are handled in the email OTP plugin. The expected behavior of returning an error for `USER_NOT_FOUND` may not be occurring because the logic has changed in how background tasks handle exceptions. This could result in errors being caught and not properly reported back to the client. A PR aimed at improving error handling suggests that the new error handling logic may need adjustments to ensure that the `APIError` will appropriately inform the client when a user is not found. As for your next steps, consider the following options: 1. Review the current plugin implementation and see if any other handling logic can be adjusted to catch these errors. 2. If necessary, tag @bekacru or @ping__ for assistance since they can provide more specific insights or potential workarounds for this recent change. What specific changes would you be willing to make in your implementation to adapt to this new behavior? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@juanbill commented on GitHub (Jan 14, 2026):

Hey @Bekacru and @ping-maxwell

Tagging you both for guidance on this issue.

<!-- gh-comment-id:3747414914 --> @juanbill commented on GitHub (Jan 14, 2026): Hey @Bekacru and @ping-maxwell Tagging you both for guidance on this issue.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 14, 2026):

Yes, this is the intended behavior in better-auth 1.4.7 and later. Errors like USER_NOT_FOUND thrown in sendVerificationOTP are now caught by the background task runner and do not propagate to the client. Instead, the client always receives a generic success response. This change was made deliberately to prevent user enumeration attacks and aligns with privacy-preserving best practices (source, source).

If you need to log or handle missing user events, you can use an after hook to intercept these cases and take custom actions, but the default is to mask errors for privacy. For example:

import { createAuthMiddleware } from "better-auth/api";

export const auth = betterAuth({
  // ...your config
  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      if (
        ctx.path === "/forget-password/email-otp" &&
        ctx.error?.message === "User not found"
      ) {
        ctx.context.logger.error("reset password: user not found", { email: ctx.body?.email });
        return ctx.json({ success: true });
      }
    }),
  },
  // ...plugins
});

See more details and community discussion here. If you need to surface errors to the client for a specific flow, you may need to implement a custom endpoint or middleware, but be aware this will reintroduce user enumeration risk.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3747417399 --> @dosubot[bot] commented on GitHub (Jan 14, 2026): <!-- Answer --> Yes, this is the intended behavior in better-auth 1.4.7 and later. Errors like `USER_NOT_FOUND` thrown in `sendVerificationOTP` are now caught by the background task runner and do not propagate to the client. Instead, the client always receives a generic success response. This change was made deliberately to prevent user enumeration attacks and aligns with privacy-preserving best practices ([source](https://github.com/better-auth/better-auth/issues/2803), [source](https://github.com/better-auth/better-auth/discussions/3972)). If you need to log or handle missing user events, you can use an after hook to intercept these cases and take custom actions, but the default is to mask errors for privacy. For example: ```typescript import { createAuthMiddleware } from "better-auth/api"; export const auth = betterAuth({ // ...your config hooks: { after: createAuthMiddleware(async (ctx) => { if ( ctx.path === "/forget-password/email-otp" && ctx.error?.message === "User not found" ) { ctx.context.logger.error("reset password: user not found", { email: ctx.body?.email }); return ctx.json({ success: true }); } }), }, // ...plugins }); ``` See more details and community discussion [here](https://github.com/better-auth/better-auth/discussions/3972). If you need to surface errors to the client for a specific flow, you may need to implement a custom endpoint or middleware, but be aware this will reintroduce user enumeration risk. <!-- 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/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/9f31a35f-8b20-48a9-92c6-72253f2670cc?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Yes%2C%20this%20is%20the%20intended%20behavior%20in%20better-auth%201.4.7%20and%20later.%20Errors%20like%20%60USER_NOT_FOUND%60%20thrown%20in%20%60sendVerificationOTP%60%20are%20now%20caught%20by%20the%20background%20task%20runner%20and%20do%20not%20propagate%20to%20the%20client.%20Instead%2C%20the%20client%20always%20receives%20a%20generic%20success%20response.%20This%20change%20was%20made%20deliberately%20to%20prevent%20user%20enumeration%20attacks%20and%20aligns%20with%20privacy-preserving%20best%20practices%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/2803%29%2C%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/discussions/3972%29%29.%0A%0AIf%20you%20need%20to%20log%20or%20handle%20missing%20user%20events%2C%20you%20can%20use%20an%20after%20hook%20to%20intercept%20these%20cases%20and%20take%20custom%20actions%2C%20but%20the%20default%20is%20to%20mask%20errors%20for%20privacy.%20For%20example%3A%0A%0A%60%60%60typescript%0Aimport%20%7B%20createAuthMiddleware%20%7D%20from%20%22better-auth/api%22%3B%0A%0Aexport%20const%20auth%20%3D%20betterAuth%28%7B%0A%20%20//%20...your%20config%0A%20%20hooks%3A%20%7B%0A%20%20%20%20after%3A%20createAuthMiddleware%28async%20%28ctx%29%20%3D%3E%20%7B%0A%20%20%20%20%20%20if%20%28%0A%20%20%20%20%20%20%20%20ctx.path%20%3D%3D%3D%20%22/forget-password/email-otp%22%20%26%26%0A%20%20%20%20%20%20%20%20ctx.error%3F.message%20%3D%3D%3D%20%22User%20not%20found%22%0A%20%20%20%20%20%20%29%20%7B%0A%20%20%20%20%20%20%20%20ctx.context.logger.error%28%22reset%20password%3A%20user%20not%20found%22%2C%20%7B%20email%3A%20ctx.body%3F.email%20%7D%29%3B%0A%20%20%20%20%20%20%20%20return%20ctx.json%28%7B%20success%3A%20true%20%7D%29%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%29%2C%0A%20%20%7D%2C%0A%20%20//%20...plugins%0A%7D%29%3B%0A%60%60%60%0A%0ASee%20more%20details%20and%20community%20discussion%20%5Bhere%5D%28https%3A//github.com/better-auth/better-auth/discussions/3972%29.%20If%20you%20need%20to%20surface%20errors%20to%20the%20client%20for%20a%20specific%20flow%2C%20you%20may%20need%20to%20implement%20a%20custom%20endpoint%20or%20middleware%2C%20but%20be%20aware%20this%20will%20reintroduce%20user%20enumeration%20risk.)&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/7348)
Author
Owner

@Bekacru commented on GitHub (Jan 14, 2026):

have you defined backgroundTasks.handler?

If so, it won’t await sending the email, it’ll just fire it inside waitUntil (or whatever handler you provide)

<!-- gh-comment-id:3748071824 --> @Bekacru commented on GitHub (Jan 14, 2026): have you defined `backgroundTasks.handler`? If so, it won’t await sending the email, it’ll just fire it inside waitUntil (or whatever handler you provide)
Author
Owner

@sakura-he commented on GitHub (Feb 10, 2026):

phone number plugin too!

<!-- gh-comment-id:3878942335 --> @sakura-he commented on GitHub (Feb 10, 2026): phone number plugin too!
Author
Owner

@mustafazeydani commented on GitHub (Feb 11, 2026):

What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails.

<!-- gh-comment-id:3885513929 --> @mustafazeydani commented on GitHub (Feb 11, 2026): What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails.
Author
Owner

@vijaysingh2219 commented on GitHub (Mar 24, 2026):

What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails.

Hi @mustafazeydani ,
Did you manage to find a solution for the issue where sendVerificationEmail throws a rate limiter error but the client still receives a 200 OK response? Curious if there’s any workaround for this.

<!-- gh-comment-id:4119056159 --> @vijaysingh2219 commented on GitHub (Mar 24, 2026): > What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails. Hi @mustafazeydani , Did you manage to find a solution for the issue where sendVerificationEmail throws a rate limiter error but the client still receives a 200 OK response? Curious if there’s any workaround for this.
Author
Owner

@mustafazeydani commented on GitHub (Mar 24, 2026):

What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails.

Hi @mustafazeydani , Did you manage to find a solution for the issue where sendVerificationEmail throws a rate limiter error but the client still receives a 200 OK response? Curious if there’s any workaround for this.

Nothing really I ended up disabling better auth's rate limiter and implementing my own one.

<!-- gh-comment-id:4119158056 --> @mustafazeydani commented on GitHub (Mar 24, 2026): > > What about rate limiter errors that occur in the sendVerificationEmail callback for example? The client receives success 200 but the function fails. > > Hi [@mustafazeydani](https://github.com/mustafazeydani) , Did you manage to find a solution for the issue where sendVerificationEmail throws a rate limiter error but the client still receives a 200 OK response? Curious if there’s any workaround for this. Nothing really I ended up disabling better auth's rate limiter and implementing my own one.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28112