@@ -270,6 +270,89 @@ const response = await fetch('http://localhost:3000/api/v1/auth/send-verificatio
270270const response = await fetch (` http://localhost:3000/api/v1/auth/verify-email?token=${token } ` );
271271```
272272
273+ ### Phone-Number Authentication (SMS OTP)
274+
275+ Phone numbers are a first-class sign-in identifier (better-auth ` phoneNumber `
276+ plugin). Opt in per deployment:
277+
278+ ``` bash
279+ OS_AUTH_PHONE_NUMBER_ENABLED=true # or auth.plugins.phoneNumber in config
280+ ```
281+
282+ The plugin adds ` sys_user.phone_number ` (unique) and ` phone_number_verified ` .
283+ Phone-only employees are created by the admin create-user / import routes with
284+ an undeliverable placeholder email (` u-<random>@placeholder.invalid ` ) — never
285+ by OTP self-signup.
286+
287+ #### Phone + password sign-in
288+
289+ Always available once the plugin is on:
290+
291+ ``` typescript
292+ const response = await fetch (' http://localhost:3000/api/v1/auth/sign-in/phone-number' , {
293+ method: ' POST' ,
294+ headers: { ' Content-Type' : ' application/json' },
295+ body: JSON .stringify ({ phoneNumber: ' +8613800000000' , password: ' password123' })
296+ });
297+ ```
298+
299+ #### OTP sign-in and self-service reset (requires SMS delivery)
300+
301+ The OTP surface opens only when a ** deliverable SMS service** is configured
302+ (` services.sms ` , Setup → Settings → SMS Delivery); without one these endpoints
303+ fail loudly with ` NOT_SUPPORTED ` . The public config advertises real
304+ availability as ` features.phoneNumberOtp ` , which is what the Console login UI
305+ gates its "Sign in with verification code" mode and the SMS reset branch on.
306+
307+ ``` typescript
308+ // Sign-in / verification: request a code, then verify (creates a session)
309+ await fetch (' /api/v1/auth/phone-number/send-otp' , {
310+ method: ' POST' ,
311+ headers: { ' Content-Type' : ' application/json' },
312+ body: JSON .stringify ({ phoneNumber: ' +8613800000000' })
313+ });
314+ await fetch (' /api/v1/auth/phone-number/verify' , {
315+ method: ' POST' ,
316+ headers: { ' Content-Type' : ' application/json' },
317+ body: JSON .stringify ({ phoneNumber: ' +8613800000000' , code: ' 123456' })
318+ });
319+
320+ // Self-service password reset over SMS
321+ await fetch (' /api/v1/auth/phone-number/request-password-reset' , {
322+ method: ' POST' ,
323+ headers: { ' Content-Type' : ' application/json' },
324+ body: JSON .stringify ({ phoneNumber: ' +8613800000000' })
325+ });
326+ await fetch (' /api/v1/auth/phone-number/reset-password' , {
327+ method: ' POST' ,
328+ headers: { ' Content-Type' : ' application/json' },
329+ body: JSON .stringify ({ phoneNumber: ' +8613800000000' , otp: ' 123456' , newPassword: ' NewPass123!' })
330+ });
331+ ```
332+
333+ Abuse hardening ships with the feature (SMS is a paid channel):
334+
335+ - ** Per-number admission guard** (always on): 60s cooldown + 5 sends per
336+ rolling hour per number, shared across the sign-in and reset flows and —
337+ in a cluster — across nodes via the shared KV. Rejected requests get an
338+ honest ` 429 ` with the retry window and never invalidate an already
339+ delivered code. Tune via ` AuthManager ` ` phoneOtp `
340+ (` cooldownSeconds ` / ` maxPerHour ` / ` allowedAttempts ` / ` expiresIn ` /
341+ ` otpLength ` ).
342+ - ** Wrong-code attempts** are capped at 3 (` allowedAttempts ` ), then the code
343+ is invalidated.
344+ - ** Per-IP rate limiting** : the plugin ships a ` /phone-number/* ` default
345+ (10/min), and the ` auth ` settings ` rate_limit_max ` /
346+ ` rate_limit_window_seconds ` tighten all four OTP endpoints.
347+ - ** OTP codes never reach logs** — the SMS service logs masked numbers and
348+ statuses only; ` request-password-reset ` always answers ` {status:true} ` so
349+ it cannot be used as an account-existence oracle.
350+
351+ The identity import endpoint's ` invite ` policy also supports phone-only rows
352+ when SMS is deliverable: the created account receives a ** credential-free
353+ invitation SMS** and the employee completes first sign-in via phone OTP, then
354+ sets their own password.
355+
273356---
274357
275358## OAuth Providers
0 commit comments