Phone Number Authentication#
The phoneNumber plugin (id: "phone-number") adds phone-based sign-in, OTP verification, and password reset flows to better-auth. It lives in packages/better-auth/src/plugins/phone-number/ and registers five HTTP endpoints. See the official docs for setup and client-side usage.
Schema#
The plugin extends the user table with two fields :
| Field | Type | Notes |
|---|---|---|
phoneNumber | string | Optional, unique, sortable |
phoneNumberVerified | boolean | Optional; excluded from user input, set only by the server |
Phone numbers cannot be updated directly via /update-user — changing phoneNumber to a non-null value is blocked by a before hook . Setting it to null (disassociation) is allowed and automatically resets phoneNumberVerified to false via a database hook .
Endpoints#
All five endpoints are defined in routes.ts and registered in index.ts:
| Endpoint | Method | Path | Client method |
|---|---|---|---|
| Sign in | POST | /sign-in/phone-number | authClient.signIn.phoneNumber |
| Send OTP | POST | /phone-number/send-otp | authClient.phoneNumber.sendOtp |
| Verify phone | POST | /phone-number/verify | authClient.phoneNumber.verify |
| Request password reset | POST | /phone-number/request-password-reset | — |
| Reset password | POST | /phone-number/reset-password | — |
Sign-In Flow (/sign-in/phone-number)#
Accepts { phoneNumber, password, rememberMe? } .
- Optional
phoneNumberValidatorruns first . - Looks up the user by
phoneNumber; looks up theircredentialaccount for password verification . - If
requireVerification: trueandphoneNumberVerifiedis false, a fresh OTP is sent and the request is rejected withPHONE_NUMBER_NOT_VERIFIED. - On success, calls
internalAdapter.createSession(userId, rememberMe === false)— passingtrueas the second arg disables the extended session TTL . - Sets the session cookie via
setSessionCookie, also forwarding therememberMeflag.
OTP Send and Verify Flow#
Sending (/phone-number/send-otp)#
Generates an OTP with generateRandomString(otpLength, "0-9") and stores it as "{code}:0" — the suffix is the attempt counter — via internalAdapter.createVerificationValue . The sendOTP callback is dispatched in the background if a background-task handler is configured .
Verifying (/phone-number/verify)#
Accepts { phoneNumber, code, disableSession?, updatePhoneNumber? }.
- Custom provider path: If
verifyOTPis configured, it delegates to that callback and then cleans up the stored verification value . - Built-in path: Calls
verifyPhoneNumberOTP(described below).
After successful OTP verification:
- If
updatePhoneNumber: trueand a session exists, updates the current user's phone number and marks it verified . - Otherwise, looks up (or creates via
signUpOnVerification) the user and marksphoneNumberVerified: true. - Creates a session unless
disableSession: true. - Fires
callbackOnVerificationafter any update .
signUpOnVerification#
When set, a user without an existing account is created with a temporary email (getTempEmail(phoneNumber)) and optional name (getTempName?) . This enables a fully passwordless phone-number signup flow.
Atomic OTP Verification & Rate Limiting#
Attempt Tracking and Atomic Consumption#
The verifyPhoneNumberOTP function is the race gate for all OTP checks (including password reset). Key design points:
- Attempt check: Before consuming the row, it reads the attempt count from the stored
"{code}:{attempts}"value. Ifattempts >= allowedAttempts(default 3), the row is deleted andTOO_MANY_ATTEMPTSis thrown . - Atomic consume: Calls
internalAdapter.consumeVerificationValue(identifier)— this atomically deletes-and-returns the row. The first concurrent caller wins; any racer gettingnullis rejected withINVALID_OTP. - Wrong code: If the code doesn't match, the row is recreated with an incremented attempt count and the original expiry . Once
allowedAttemptsis reached, the row is not recreated.
The attempt counter is stored in-band with the OTP value ("{otp}:{n}") so it persists across recreations without an extra DB column.
Rate Limiting#
The plugin registers a rate limit rule for all /phone-number/* paths: 10 requests per 60-second window . Note: /sign-in/phone-number does not match this pattern and relies on the global rate limiter.
Password Reset via Phone#
- Request (
/phone-number/request-password-reset): Generates an OTP stored under key"{phoneNumber}-request-password-reset"and callssendPasswordResetOTP. Always returns{ status: true }regardless of whether the phone number exists, preventing enumeration . - Reset (
/phone-number/reset-password): Verifies OTP using the sameverifyPhoneNumberOTPfunction (with the prefixed identifier), then creates or updates thecredentialaccount password. RespectsrevokeSessionsOnPasswordResetif configured .
Configuration Reference#
All options are defined in types.ts. Key options:
| Option | Default | Description |
|---|---|---|
otpLength | 6 | Length of generated OTP |
expiresIn | 300 (sec) | OTP TTL in seconds |
allowedAttempts | 3 | Max wrong guesses before OTP is invalidated |
requireVerification | false | Block sign-in until phone is verified |
sendOTP | required | (params, ctx) => void/Promise — delivers the code |
verifyOTP | optional | Custom verification callback (e.g., Twilio Verify) |
phoneNumberValidator | optional | Custom phone format validator |
signUpOnVerification | optional | Auto-create user on first OTP verification |
callbackOnVerification | optional | Hook called after each successful verification |
sendPasswordResetOTP | optional | Callback for password reset OTP delivery |
Key Source Files#
| File | Purpose |
|---|---|
plugins/phone-number/index.ts | Plugin factory, rate limit config, hooks |
plugins/phone-number/routes.ts | All endpoint handlers + verifyPhoneNumberOTP |
plugins/phone-number/types.ts | PhoneNumberOptions interface |
plugins/phone-number/schema.ts | DB schema additions |
plugins/phone-number/error-codes.ts | PHONE_NUMBER_ERROR_CODES |