Phone Number OTP Verification Fails - Unable to Sign Up New Users with signUpOnVerification #2085

Closed
opened 2026-03-13 09:26:01 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @hixbehq on GitHub (Oct 7, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I'm trying to implement phone number login and sign-up using the phone number plugin with OTP verification, but I'm unable to verify the OTP and create new users as described in the documentation.
Configuration:

phoneNumberPlugin({
    sendOTP: async ({ phoneNumber, code }, request) => {
        await sendPhoneOtp(phoneNumber, code);
    },
    signUpOnVerification: {
        getTempEmail: (phoneNumber) => `${phoneNumber}@eposora.com`,
        getTempName: (phoneNumber) => phoneNumber,
    }
})

Implementation:

javascript// Sending OTP (this works fine)
export const sendPhoneOTP = async (phoneNumber) => {
    try {
        await auth.api.sendPhoneNumberOTP({
            body: {
                phoneNumber,
            },
        })

        return {
            success: true,
            message: "OTP sent successfully."
        }
    } catch (error) {
        return {
            success: false,
            message: error.message || "An unknown error occurred."
        }
    }
}

// Verifying OTP (this is where the issue is)
export const verifyPhoneOTP = async (phoneNumber, otp) => {
    console.log(phoneNumber, otp)
    try {
        const data = await auth.api.verifyPhoneNumber({
            body: {
                phoneNumber,
                code: otp,
                disableSession: false,
                updatePhoneNumber: true,
            },
        })
        console.log(data)

        return {
            success: true,
            message: "Verified successfully."
        }
    } catch (error) {
        return {
            success: false,
            message: error.message || "An unknown error occurred."
        }
    }
}

Current vs. Expected behavior

According to the documentation, when signUpOnVerification is configured, verifying an OTP for a non-existent user should:

Create a new user account
Use the temporary email from getTempEmail(phoneNumber)
Create a session for the user

Actual Behavior:
When I try to verify the OTP using auth.api.verifyPhoneNumber, I get:

Error message: "User not found"
No new user is created

Attempted Solutions:

auth.api.sendPhoneNumberOTP - Works fine
auth.api.verifyPhoneNumber - Returns "User not found" instead of creating user
auth.api.signInPhoneNumber - Requires password, throws "Invalid body parameters"
auth.api.verifyPhoneNumberOTP - Does not exist

Questions:

What is the correct API method to verify the OTP and trigger the signUpOnVerification flow?
Is there a different endpoint or parameter I should be using?
Should I be using a direct fetch call to a specific route instead of auth.api.*?

Environment:

Better Auth version: [your version]
Framework: Next.js [your version]
Plugin: phoneNumberPlugin with signUpOnVerification configured

Documentation Reference:
https://www.better-auth.com/docs/plugins/phone-number
The documentation shows the signUpOnVerification configuration but doesn't clearly specify which API method should be used on the client side to verify the OTP and trigger user creation.

What version of Better Auth are you using?

1.3.26

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Pro",
    "release": "10.0.26100",
    "cpuCount": 12,
    "cpuModel": "12th Gen Intel(R) Core(TM) i5-12400",
    "totalMemory": "31.75 GB",
    "freeMemory": "6.35 GB"
  },
  "node": {
    "version": "v22.18.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.3"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "^15.4.6"
    },
    {
      "name": "react",
      "version": "^19.1.1"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    },
    {
      "name": "drizzle",
      "version": "^0.44.6"
    }
  ],
  "betterAuth": {
    "version": "^1.3.26",
    "config": null
  }
}

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

Package

Auth config (if applicable)

import { Resend } from "resend";
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { passkey } from "better-auth/plugins/passkey"
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { openAPI, magicLink, lastLoginMethod, phoneNumber as phoneNumberPlugin } from "better-auth/plugins";

import { sendPhoneOtp } from "../server";
import { schema, database } from "../database";
import VerifyEmail from "../emails/verify-email";
import ForgotPasswordEmail from "../emails/reset-password";

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
    emailVerification: {
        sendVerificationEmail: async ({ user, url }) => {
            resend.emails.send({
                from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
                to: user.email,
                subject: "Verify your email",
                react: VerifyEmail({ username: user.name, verifyUrl: url }),
            });
        },
        sendOnSignUp: true,
    },
    socialProviders: {
        google: {
            clientId: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
            accessType: "offline", 
            prompt: "select_account consent",
        },
        github: { 
            clientId: process.env.GITHUB_CLIENT_ID, 
            clientSecret: process.env.GITHUB_CLIENT_SECRET, 
        },
        facebook: { 
            clientId: process.env.FACEBOOK_CLIENT_ID, 
            clientSecret: process.env.FACEBOOK_CLIENT_SECRET, 
        },
        twitter: { 
            clientId: process.env.TWITTER_CLIENT_ID, 
            clientSecret: process.env.TWITTER_CLIENT_SECRET, 
        }, 
    },
    emailAndPassword: {
        enabled: true,
        sendResetPassword: async ({ user, url }) => {
            resend.emails.send({
                from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
                to: user.email,
                subject: "Reset your password",
                react: ForgotPasswordEmail({ username: user.name, resetUrl: url, userEmail: user.email }),
            });
        },
        requireEmailVerification: true
    },
    database: drizzleAdapter(database, {
        provider: "pg",
        schema,
        usePlural: true,
    }),
    plugins: [
        magicLink({
            sendMagicLink: async ({ email, url }, request) => {
                resend.emails.send({
                    from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
                    to: email,
                    subject: "Verify your email",
                    react: VerifyEmail({ verifyUrl: url }),
                });
            }
        }),
        phoneNumberPlugin({
            // allowedAttempts: 3,
            sendOTP: async  ({ phoneNumber, code }, request) => {
                await sendPhoneOtp(phoneNumber, code);
            },
            signUpOnVerification: {
                getTempEmail: (phoneNumber) => `${phoneNumber}@eposora.com`,
                //optionally, you can also pass `getTempName` function to generate a temporary name for the user
                getTempName: (phoneNumber) => phoneNumber, //by default, it will use the phone number as the name
            }
        }),
        passkey(),
        lastLoginMethod({
            cookieName: "eposora_last_login_method",
            maxAge: 60 * 60 * 24 * 30,
            storeInDatabase: true,
        }),
        openAPI(),
        nextCookies()],
    account: {
        accountLinking: {
            enabled: true,
            trustedProviders: ["google", "github", "facebook", "twitter"]
        }
    },
    appName: "Eposora Auth System",
    cors: {
        origin: ["http://localhost:4209"],
        credentials: true,
    },
    baseURL: process.env.NODE_ENV === "production"
        ? "https://auth.example.com"
        : "http://localhost:4209",
    credentials: "include",
    session: {
        cookieCache: {
            enabled: true,
            maxAge: 60 * 60 * 24 * 7,
        },
    },
    advanced: {
        cookiePrefix: "eposora",
        cookies: {
            session_token: {
                name: "eposora_session_token",
                attributes: {
                    path: "/",
                    secure: process.env.NODE_ENV === "production",
                    httpOnly: true,
                    sameSite: "none",
                    maxAge: 60 * 60 * 24 * 7,
                },
            },
            session_data: {
                name: "eposora_session_data",
                attributes: {
                    path: "/",
                    secure: process.env.NODE_ENV === "production",
                    httpOnly: true,
                    sameSite: "none",
                    maxAge: 60 * 60 * 24 * 7,
                },
            },
            dont_remember: {
                name: "eposora_dont_remember",
                attributes: {
                    path: "/",
                    secure: process.env.NODE_ENV === "production",
                    httpOnly: true,
                    sameSite: "none",
                    maxAge: 60 * 60 * 24 * 7,
                },
            },
        },
        useSecureCookies: true,
        trustedOrigins: ["http://localhost:4209"],
    }
});

Additional context

No response

Originally created by @hixbehq on GitHub (Oct 7, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I'm trying to implement phone number login and sign-up using the phone number plugin with OTP verification, but I'm unable to verify the OTP and create new users as described in the documentation. Configuration: ``` phoneNumberPlugin({ sendOTP: async ({ phoneNumber, code }, request) => { await sendPhoneOtp(phoneNumber, code); }, signUpOnVerification: { getTempEmail: (phoneNumber) => `${phoneNumber}@eposora.com`, getTempName: (phoneNumber) => phoneNumber, } }) ``` Implementation: ``` javascript// Sending OTP (this works fine) export const sendPhoneOTP = async (phoneNumber) => { try { await auth.api.sendPhoneNumberOTP({ body: { phoneNumber, }, }) return { success: true, message: "OTP sent successfully." } } catch (error) { return { success: false, message: error.message || "An unknown error occurred." } } } // Verifying OTP (this is where the issue is) export const verifyPhoneOTP = async (phoneNumber, otp) => { console.log(phoneNumber, otp) try { const data = await auth.api.verifyPhoneNumber({ body: { phoneNumber, code: otp, disableSession: false, updatePhoneNumber: true, }, }) console.log(data) return { success: true, message: "Verified successfully." } } catch (error) { return { success: false, message: error.message || "An unknown error occurred." } } } ``` ### Current vs. Expected behavior According to the documentation, when signUpOnVerification is configured, verifying an OTP for a non-existent user should: Create a new user account Use the temporary email from getTempEmail(phoneNumber) Create a session for the user Actual Behavior: When I try to verify the OTP using auth.api.verifyPhoneNumber, I get: Error message: "User not found" No new user is created Attempted Solutions: ✅ auth.api.sendPhoneNumberOTP - Works fine ❌ auth.api.verifyPhoneNumber - Returns "User not found" instead of creating user ❌ auth.api.signInPhoneNumber - Requires password, throws "Invalid body parameters" ❌ auth.api.verifyPhoneNumberOTP - Does not exist Questions: What is the correct API method to verify the OTP and trigger the signUpOnVerification flow? Is there a different endpoint or parameter I should be using? Should I be using a direct fetch call to a specific route instead of auth.api.*? Environment: Better Auth version: [your version] Framework: Next.js [your version] Plugin: phoneNumberPlugin with signUpOnVerification configured Documentation Reference: https://www.better-auth.com/docs/plugins/phone-number The documentation shows the signUpOnVerification configuration but doesn't clearly specify which API method should be used on the client side to verify the OTP and trigger user creation. ### What version of Better Auth are you using? 1.3.26 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Pro", "release": "10.0.26100", "cpuCount": 12, "cpuModel": "12th Gen Intel(R) Core(TM) i5-12400", "totalMemory": "31.75 GB", "freeMemory": "6.35 GB" }, "node": { "version": "v22.18.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.3" }, "frameworks": [ { "name": "next", "version": "^15.4.6" }, { "name": "react", "version": "^19.1.1" } ], "databases": [ { "name": "pg", "version": "^8.16.3" }, { "name": "drizzle", "version": "^0.44.6" } ], "betterAuth": { "version": "^1.3.26", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Package ### Auth config (if applicable) ```typescript import { Resend } from "resend"; import { betterAuth } from "better-auth"; import { nextCookies } from "better-auth/next-js"; import { passkey } from "better-auth/plugins/passkey" import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { openAPI, magicLink, lastLoginMethod, phoneNumber as phoneNumberPlugin } from "better-auth/plugins"; import { sendPhoneOtp } from "../server"; import { schema, database } from "../database"; import VerifyEmail from "../emails/verify-email"; import ForgotPasswordEmail from "../emails/reset-password"; const resend = new Resend(process.env.RESEND_API_KEY); export const auth = betterAuth({ emailVerification: { sendVerificationEmail: async ({ user, url }) => { resend.emails.send({ from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`, to: user.email, subject: "Verify your email", react: VerifyEmail({ username: user.name, verifyUrl: url }), }); }, sendOnSignUp: true, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, accessType: "offline", prompt: "select_account consent", }, github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }, facebook: { clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET, }, twitter: { clientId: process.env.TWITTER_CLIENT_ID, clientSecret: process.env.TWITTER_CLIENT_SECRET, }, }, emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { resend.emails.send({ from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`, to: user.email, subject: "Reset your password", react: ForgotPasswordEmail({ username: user.name, resetUrl: url, userEmail: user.email }), }); }, requireEmailVerification: true }, database: drizzleAdapter(database, { provider: "pg", schema, usePlural: true, }), plugins: [ magicLink({ sendMagicLink: async ({ email, url }, request) => { resend.emails.send({ from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`, to: email, subject: "Verify your email", react: VerifyEmail({ verifyUrl: url }), }); } }), phoneNumberPlugin({ // allowedAttempts: 3, sendOTP: async ({ phoneNumber, code }, request) => { await sendPhoneOtp(phoneNumber, code); }, signUpOnVerification: { getTempEmail: (phoneNumber) => `${phoneNumber}@eposora.com`, //optionally, you can also pass `getTempName` function to generate a temporary name for the user getTempName: (phoneNumber) => phoneNumber, //by default, it will use the phone number as the name } }), passkey(), lastLoginMethod({ cookieName: "eposora_last_login_method", maxAge: 60 * 60 * 24 * 30, storeInDatabase: true, }), openAPI(), nextCookies()], account: { accountLinking: { enabled: true, trustedProviders: ["google", "github", "facebook", "twitter"] } }, appName: "Eposora Auth System", cors: { origin: ["http://localhost:4209"], credentials: true, }, baseURL: process.env.NODE_ENV === "production" ? "https://auth.example.com" : "http://localhost:4209", credentials: "include", session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7, }, }, advanced: { cookiePrefix: "eposora", cookies: { session_token: { name: "eposora_session_token", attributes: { path: "/", secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "none", maxAge: 60 * 60 * 24 * 7, }, }, session_data: { name: "eposora_session_data", attributes: { path: "/", secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "none", maxAge: 60 * 60 * 24 * 7, }, }, dont_remember: { name: "eposora_dont_remember", attributes: { path: "/", secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "none", maxAge: 60 * 60 * 24 * 7, }, }, }, useSecureCookies: true, trustedOrigins: ["http://localhost:4209"], } }); ``` ### Additional context _No response_
GiteaMirror added the plugin label 2026-03-13 09:26:01 -05:00
Author
Owner

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

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

The issue is caused by the use of updatePhoneNumber: true in your OTP verification request. When updatePhoneNumber is set, the API expects an existing session and tries to update the phone number for the current user. If there is no session, it throws a "User not found" error and does not trigger the sign-up flow.

To create a new user via OTP verification (with signUpOnVerification configured), remove updatePhoneNumber from your request body. Your verification call should look like:

const data = await auth.api.verifyPhoneNumber({
  body: {
    phoneNumber,
    code: otp,
    disableSession: false
    // Do NOT include updatePhoneNumber
  }
})

This will allow the endpoint to create a new user if one does not exist, using your getTempEmail and getTempName functions, and start a session as expected. See the implementation details here.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Oct 7, 2025): <!-- Greeting --> Hi @webxbeyond! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The issue is caused by the use of `updatePhoneNumber: true` in your OTP verification request. When `updatePhoneNumber` is set, the API expects an existing session and tries to update the phone number for the current user. If there is no session, it throws a "User not found" error and does not trigger the sign-up flow. To create a new user via OTP verification (with `signUpOnVerification` configured), remove `updatePhoneNumber` from your request body. Your verification call should look like: ```js const data = await auth.api.verifyPhoneNumber({ body: { phoneNumber, code: otp, disableSession: false // Do NOT include updatePhoneNumber } }) ``` This will allow the endpoint to create a new user if one does not exist, using your `getTempEmail` and `getTempName` functions, and start a session as expected. See the implementation details [here](https://github.com/better-auth/better-auth/blob/9b1c5d885d16c5101c089f09cce13f1da64dac0b/packages/better-auth/src/plugins/phone-number/index.ts). <!-- 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/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/be7debdd-e279-4428-a12c-4840cc67d9e4?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/5142)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2085