feat(phone-number): add server-side OTP consumption API (#9766)

This commit is contained in:
Gautam Manchandani
2026-06-11 16:58:16 -07:00
committed by GitHub
parent b36c38f984
commit bf39cbf13f
5 changed files with 275 additions and 74 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": minor
---
Add a server-only `auth.api.consumePhoneNumberOTP` API for custom phone OTP flows that need to verify and consume a code without creating or updating users or sessions.
@@ -110,6 +110,33 @@ After the OTP is sent, users can verify their phone number by providing the code
```
</APIMethod>
### Consume an OTP on the Server
For custom sign-up or account-linking flows, consume an OTP without creating or
updating a user or session. This server-only API is exposed through `auth.api`;
it does not register an HTTP route or generate an `authClient` method.
```ts title="auth.ts"
const result = await auth.api.consumePhoneNumberOTP({
body: {
phoneNumber: "+1234567890",
code: "123456"
}
})
```
This method uses the same validation and consumption behavior as
`verifyPhoneNumber`; it does not introduce a stronger concurrency guarantee.
If your server flow can receive parallel redemption attempts and requires
strict single-use acceptance, configure [`verifyOTP`](#verifyotp) with a
provider that atomically consumes accepted codes.
Call this method inside the server-side flow that uses the verification result;
it does not return a session or reusable proof of verification. Validate any
inputs that do not depend on successful OTP verification first. If you expose
this capability through your own public endpoint, apply rate limiting and
appropriate abuse protection there.
### Allow Sign-Up with Phone Number
To allow users to sign up using their phone number, you can pass `signUpOnVerification` option to your plugin configuration. It requires you to pass `getTempEmail` function to generate a temporary email for the user.
@@ -6,6 +6,7 @@ import { PACKAGE_VERSION } from "../../version";
import { PHONE_NUMBER_ERROR_CODES } from "./error-codes";
import type { RequiredPhoneNumberOptions } from "./routes";
import {
consumePhoneNumberOTP,
requestPasswordResetPhoneNumber,
resetPasswordPhoneNumber,
sendPhoneNumberOTP,
@@ -87,6 +88,9 @@ export const phoneNumber = (options?: PhoneNumberOptions | undefined) => {
sendPhoneNumberOTP: sendPhoneNumberOTP(
opts as RequiredPhoneNumberOptions,
),
consumePhoneNumberOTP: consumePhoneNumberOTP(
opts as RequiredPhoneNumberOptions,
),
verifyPhoneNumber: verifyPhoneNumber(opts as RequiredPhoneNumberOptions),
requestPasswordResetPhoneNumber: requestPasswordResetPhoneNumber(
opts as RequiredPhoneNumberOptions,
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { bearer } from "../bearer";
import { phoneNumber } from ".";
@@ -106,6 +106,126 @@ describe("phone-number", async () => {
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/9754
*/
describe("consume phone-number OTP", async () => {
let otp = "";
const callbackOnVerification = vi.fn();
const { auth, client, db } = await getTestInstance(
{
plugins: [
phoneNumber({
async sendOTP({ code }) {
otp = code;
},
callbackOnVerification,
}),
],
},
{
disableTestUser: true,
clientOptions: {
plugins: [phoneNumberClient()],
},
},
);
it("consumes an OTP server-side without creating a user or session", async () => {
const phoneNumber = "+251911121301";
await client.phoneNumber.sendOtp({ phoneNumber });
const result = await auth.api.consumePhoneNumberOTP({
body: { phoneNumber, code: otp },
});
expect(result).toEqual({ status: true });
expect(callbackOnVerification).not.toHaveBeenCalled();
expect(
await db.findMany({
model: "user",
where: [{ field: "phoneNumber", value: phoneNumber }],
}),
).toHaveLength(0);
expect(
await (await auth.$context).internalAdapter.findVerificationValue(
phoneNumber,
),
).toBeNull();
});
it("is exposed through the server API only", async () => {
expectTypeOf<typeof auth.api>().toHaveProperty("consumePhoneNumberOTP");
expectTypeOf<typeof client.phoneNumber>().not.toHaveProperty("consumeOtp");
const response = await client.$fetch("/phone-number/consume-otp", {
method: "POST",
body: { phoneNumber: "+251911121302", code: "123456" },
});
expect(response.error?.status).toBe(404);
});
it("keeps attempt limiting active when a stored attempt count is corrupt", async () => {
const phoneNumber = "+251911121303";
await client.phoneNumber.sendOtp({ phoneNumber });
await (await auth.$context).internalAdapter.updateVerificationByIdentifier(
phoneNumber,
{
value: `${otp}:not-a-number`,
},
);
for (let attempt = 0; attempt < 3; attempt++) {
const res = await client.phoneNumber.verify({
phoneNumber,
code: "000000",
});
expect(res.error?.status).toBe(400);
expect(res.error?.message).toBe("Invalid OTP");
}
const blocked = await client.phoneNumber.verify({
phoneNumber,
code: "000000",
});
expect(blocked.error?.status).toBe(403);
expect(blocked.error?.message).toBe("Too many attempts");
});
it("honors allowedAttempts set to 0", async () => {
let zeroAttemptOtp = "";
const { auth: zeroAttemptAuth, client: zeroAttemptClient } =
await getTestInstance(
{
plugins: [
phoneNumber({
async sendOTP({ code }) {
zeroAttemptOtp = code;
},
allowedAttempts: 0,
}),
],
},
{
disableTestUser: true,
clientOptions: {
plugins: [phoneNumberClient()],
},
},
);
const testPhoneNumber = "+251911121304";
await zeroAttemptClient.phoneNumber.sendOtp({
phoneNumber: testPhoneNumber,
});
await expect(
zeroAttemptAuth.api.consumePhoneNumberOTP({
body: { phoneNumber: testPhoneNumber, code: zeroAttemptOtp },
}),
).rejects.toThrow("Too many attempts");
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/4839
*/
@@ -1035,7 +1155,7 @@ describe("signUpOnVerification with additionalFields", async () => {
describe("custom verifyOTP", async () => {
const mockVerifyOTP = vi.fn();
const { client, sessionSetter } = await getTestInstance(
const { auth, client, sessionSetter } = await getTestInstance(
{
plugins: [
phoneNumber({
@@ -1168,6 +1288,30 @@ describe("custom verifyOTP", async () => {
expect(user.data?.user.phoneNumber).toBe(updatedPhoneNumber);
expect(user.data?.user.phoneNumberVerified).toBe(true);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9754
*/
it("should delegate server-side consumption to custom verifyOTP without an internal record", async () => {
const externalPhoneNumber = "+12223334444";
mockVerifyOTP.mockResolvedValueOnce(true);
const result = await auth.api.consumePhoneNumberOTP({
body: {
phoneNumber: externalPhoneNumber,
code: "external-code",
},
});
expect(result).toEqual({ status: true });
expect(mockVerifyOTP).toHaveBeenCalledWith(
{
phoneNumber: externalPhoneNumber,
code: "external-code",
},
expect.anything(),
);
});
});
// The provisioning gate reaches phone-number sign-up through the createUser
@@ -1,3 +1,4 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import * as z from "zod";
@@ -7,6 +8,7 @@ import { generateRandomString } from "../../crypto/random";
import { parseUserInput } from "../../db";
import { parseUserOutput } from "../../db/schema";
import type { Account } from "../../types";
import { HIDE_METADATA } from "../../utils";
import { getDate } from "../../utils/date";
import { PHONE_NUMBER_ERROR_CODES } from "./error-codes";
import type { PhoneNumberOptions, UserWithPhoneNumber } from "./types";
@@ -208,6 +210,15 @@ const sendPhoneNumberOTPBodySchema = z.object({
}),
});
const consumePhoneNumberOTPBodySchema = z.object({
phoneNumber: z.string().meta({
description: 'Phone number to verify. Eg: "+1234567890"',
}),
code: z.string().meta({
description: 'OTP code. Eg: "123456"',
}),
});
/**
* ### Endpoint
*
@@ -307,6 +318,87 @@ export const sendPhoneNumberOTP = (opts: RequiredPhoneNumberOptions) =>
},
);
async function verifyAndConsumePhoneNumberOTP(
ctx: GenericEndpointContext,
opts: RequiredPhoneNumberOptions,
body: {
phoneNumber: string;
code: string;
},
) {
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(
phoneNumber,
);
return;
}
const otp =
await ctx.context.internalAdapter.findVerificationValue(phoneNumber);
if (!otp || otp.expiresAt < new Date()) {
if (otp && otp.expiresAt < new Date()) {
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
}
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
}
const [otpValue, attemptsValue] = otp.value.split(":");
const attempts = parseVerificationAttempts(attemptsValue);
const allowedAttempts = opts.allowedAttempts ?? 3;
if (attempts >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
phoneNumber,
);
throw APIError.from(
"FORBIDDEN",
PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS,
);
}
if (otpValue !== code) {
await ctx.context.internalAdapter.updateVerificationByIdentifier(
phoneNumber,
{
value: `${otpValue}:${attempts + 1}`,
},
);
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
}
await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneNumber);
}
function parseVerificationAttempts(value: string | undefined) {
const attempts = Number(value ?? 0);
return Number.isSafeInteger(attempts) && attempts > 0 ? attempts : 0;
}
/**
* ### API Methods
*
* **server:**
* `auth.api.consumePhoneNumberOTP`
*
* Verifies and consumes an OTP without updating a user or creating a session.
*/
export const consumePhoneNumberOTP = (opts: RequiredPhoneNumberOptions) =>
createAuthEndpoint(
{
method: "POST",
body: consumePhoneNumberOTPBodySchema,
metadata: HIDE_METADATA,
},
async (ctx) => {
await verifyAndConsumePhoneNumberOTP(ctx, opts, ctx.body);
return ctx.json({ status: true });
},
);
const verifyPhoneNumberBodySchema = z
.object({
/**
@@ -465,78 +557,7 @@ export const verifyPhoneNumber = (opts: RequiredPhoneNumberOptions) =>
},
},
async (ctx) => {
if (opts?.verifyOTP) {
// Use custom verifyOTP if provided
const isValid = await opts.verifyOTP(
{
phoneNumber: ctx.body.phoneNumber,
code: ctx.body.code,
},
ctx,
);
if (!isValid) {
throw APIError.from(
"BAD_REQUEST",
PHONE_NUMBER_ERROR_CODES.INVALID_OTP,
);
}
// Clean up verification value
const otp = await ctx.context.internalAdapter.findVerificationValue(
ctx.body.phoneNumber,
);
if (otp) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
ctx.body.phoneNumber,
);
}
} else {
// Default internal verification logic
const otp = await ctx.context.internalAdapter.findVerificationValue(
ctx.body.phoneNumber,
);
if (!otp || otp.expiresAt < new Date()) {
if (otp && otp.expiresAt < new Date()) {
throw APIError.from(
"BAD_REQUEST",
PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED,
);
}
throw APIError.from(
"BAD_REQUEST",
PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND,
);
}
const [otpValue, attempts] = otp.value.split(":");
const allowedAttempts = opts?.allowedAttempts || 3;
if (attempts && parseInt(attempts) >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
ctx.body.phoneNumber,
);
throw APIError.from(
"FORBIDDEN",
PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS,
);
}
if (otpValue !== ctx.body.code) {
await ctx.context.internalAdapter.updateVerificationByIdentifier(
ctx.body.phoneNumber,
{
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
},
);
throw APIError.from(
"BAD_REQUEST",
PHONE_NUMBER_ERROR_CODES.INVALID_OTP,
);
}
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
ctx.body.phoneNumber,
);
}
await verifyAndConsumePhoneNumberOTP(ctx, opts, ctx.body);
if (ctx.body.updatePhoneNumber) {
const session = await getSessionFromCtx(ctx);