fix(phone-number): use custom verifyOTP when resetting the password (#10698)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Jash Ramde Karangiya
2026-10-14 11:37:54 +01:00
committed by GitHub
co-authored by Gustavo Valverde
parent adbdc06e33
commit f0ce580fbd
4 changed files with 118 additions and 7 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
If you set the phone number plugin's `verifyOTP` option, `/phone-number/reset-password` now checks the code with it, as `/phone-number/verify` already does. Before, password reset always compared the code with the one Better Auth generated, so apps that verify codes through a provider such as Twilio Verify could not reset passwords. If your `sendPasswordResetOTP` sends the code Better Auth generates, make sure `verifyOTP` accepts that code. Apps that don't set `verifyOTP` are unaffected.
@@ -297,6 +297,8 @@ Then, you can reset the password by calling `resetPassword` on the client with t
If the new password is rejected, for example by the [Have I Been Pwned](/docs/plugins/have-i-been-pwned) plugin, the OTP isn't consumed and can be used again. If a later step of the reset fails, such as revoking sessions or saving the password, the OTP stays usable only when transactions are enabled for your database adapter and verification values are stored in the database. That is the default without secondary storage; with [secondary storage](/docs/concepts/database#secondary-storage), set [`verification.storeInDatabase: true`](/docs/reference/options#verification). A wrong OTP still counts toward `allowedAttempts`.
With a custom [`verifyOTP`](#verifyotp), your provider checks the code and usually spends it, so these guarantees don't apply. A password that is too short or too long is rejected before the provider is called, but a password rejected later, for example by Have I Been Pwned, has already spent the code, and the user needs to request a new one.
## Options
### `otpLength`
@@ -345,6 +347,8 @@ A custom function to validate the phone number. It takes the phone number as an
A custom function to verify the OTP code. When provided, this function will be used instead of the default internal verification logic. This is useful when you want to integrate with external SMS providers that handle OTP verification (e.g., Twilio Verify, AWS SNS). The function takes an object with `phoneNumber` and `code` properties and a request object, and returns a boolean or a promise that resolves to a boolean indicating whether the OTP is valid.
The function checks the codes for phone number verification, [`consumePhoneNumberOTP`](#consume-an-otp-on-the-server), and [password reset](#request-password-reset). If `sendPasswordResetOTP` sends the code Better Auth generates rather than a code from your provider, `verifyOTP` must accept that code too.
```ts
import { betterAuth } from "better-auth";
import { phoneNumber } from "better-auth/plugins"
@@ -1355,12 +1355,16 @@ describe("signUpOnVerification with additionalFields", async () => {
describe("custom verifyOTP", async () => {
const mockVerifyOTP = vi.fn();
let resetOtp = "";
const { auth, client, sessionSetter } = await getTestInstance(
{
plugins: [
phoneNumber({
async sendOTP() {},
async sendPasswordResetOTP({ code }) {
resetOtp = code;
},
verifyOTP: mockVerifyOTP,
signUpOnVerification: {
getTempEmail(phoneNumber) {
@@ -1490,6 +1494,78 @@ describe("custom verifyOTP", async () => {
expect(user.data?.user.phoneNumberVerified).toBe(true);
});
/** Signs up `phoneNumber` through the custom verifier and requests a reset. */
async function signUpAndRequestReset(phoneNumber: string) {
mockVerifyOTP.mockResolvedValueOnce(true);
await client.phoneNumber.verify({ phoneNumber, code: "123456" });
await client.phoneNumber.requestPasswordReset({ phoneNumber });
mockVerifyOTP.mockClear();
}
/**
* @see https://github.com/better-auth/better-auth/issues/10697
*/
describe("password reset", () => {
it("should use custom verifyOTP when resetting the password", async () => {
const phoneNumber = "+1222333444";
const newPassword = "new-password-123";
await signUpAndRequestReset(phoneNumber);
// The provider accepts a code that is not the stored one.
mockVerifyOTP.mockResolvedValueOnce(true);
const res = await client.phoneNumber.resetPassword({
phoneNumber,
otp: "999999",
newPassword,
});
expect(res.error).toBe(null);
expect(mockVerifyOTP).toHaveBeenCalledWith(
{ phoneNumber, code: "999999" },
expect.anything(),
);
const signIn = await client.signIn.phoneNumber({
phoneNumber,
password: newPassword,
});
expect(signIn.error).toBe(null);
});
it("should not spend the provider code when the new password is rejected", async () => {
const phoneNumber = "+1222333555";
await signUpAndRequestReset(phoneNumber);
const res = await client.phoneNumber.resetPassword({
phoneNumber,
otp: "999999",
newPassword: "short",
});
expect(res.error?.code).toBe("PASSWORD_TOO_SHORT");
expect(mockVerifyOTP).not.toHaveBeenCalled();
});
it("should reject a reset code that custom verifyOTP refuses", async () => {
const phoneNumber = "+1222333666";
await signUpAndRequestReset(phoneNumber);
// The stored code would pass the built-in check, so only the custom
// verifier can reject it.
mockVerifyOTP.mockResolvedValueOnce(false);
const res = await client.phoneNumber.resetPassword({
phoneNumber,
otp: resetOtp,
newPassword: "new-password-123",
});
expect(res.error?.code).toBe("INVALID_OTP");
expect(mockVerifyOTP).toHaveBeenCalledWith(
{ phoneNumber, code: resetOtp },
expect.anything(),
);
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/9754
*/
@@ -328,12 +328,11 @@ async function verifyAndConsumePhoneNumberOTP(
) {
const { phoneNumber, code } = body;
if (opts.verifyOTP) {
const isValid = await opts.verifyOTP({ phoneNumber, code }, ctx);
if (!isValid) {
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
}
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
await verifyWithCustomVerifier(
ctx,
opts.verifyOTP,
phoneNumber,
code,
phoneNumber,
);
return;
@@ -342,6 +341,21 @@ async function verifyAndConsumePhoneNumberOTP(
await verifyPhoneNumberOTP(ctx, opts, phoneNumber, code);
}
/** Checks the code with `verifyOTP`, then drops the code stored under `identifier`. */
async function verifyWithCustomVerifier(
ctx: GenericEndpointContext,
verifyOTP: NonNullable<RequiredPhoneNumberOptions["verifyOTP"]>,
phoneNumber: string,
code: string,
identifier: string,
) {
const isValid = await verifyOTP({ phoneNumber, code }, ctx);
if (!isValid) {
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
}
await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
}
/**
* ### API Methods
*
@@ -801,7 +815,19 @@ export const resetPasswordPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
// which counts the attempt and throws; a correct one is consumed inside
// the reset transaction.
let otpConsumed = false;
if (
if (opts.verifyOTP) {
// A custom verifier spends the code, so reject a bad password first.
assertPasswordNotTooShort(ctx, ctx.body.newPassword);
assertPasswordNotTooLong(ctx, ctx.body.newPassword);
await verifyWithCustomVerifier(
ctx,
opts.verifyOTP,
ctx.body.phoneNumber,
ctx.body.otp,
phoneResetIdentifier,
);
otpConsumed = true;
} else if (
!(await matchesUsableStoredPhoneNumberOTP(
ctx,
opts,