Approval frames · feature: signup-phone-verification · FFRNT-361
Phone verification by text message, as a step of sign-up
After an account is created, the user can confirm a mobile number with a 6-digit text-message code, before going on to the existing "check your inbox" step. These frames show the placement, the happy path, and every state the implementation must render: wrong, expired and locked codes, send failures, rate limits, no SMS channel in this environment, a number that can't be used, both skip policies, loading, resume, session and generic errors. Each is shown on desktop and at 375px. Approving a flow approves the component and package plan below. No UI exists yet.
Where the step sits
signup → phone → check inbox or the app, in parallel with the email link. The exit rule, the sign-in re-prompt, and a routing table
from GET /methods + GET /verify/status.
Enter phone
Country code + national number → E.164 preview, invalid / valid, sending.
03 · /signup/phoneEnter code
6-cell OtpInput that accepts a pasted code and SMS autofill, resend cooldown, expiry, verifying.
04 · /signup/phoneVerified → continue
Server-confirmed success only. Continue goes to check-inbox if an email is still pending, otherwise into the app.
05 · fail-closedCode errors
Wrong code (tries left), expired code, too many attempts / 429 with a countdown.
06 · fail-closedSend & channel errors
Send failed, rate-limited, SMS unavailable (honest degrade), number can't be used (no enumeration), number rejected by server.
07 · policySkip policy
optional (recommended) vs required. Neither one ever leaves
the user stuck.
Loading & edge states
Loading skeleton, already verified, resume after reload, SMS off before the step loads, session expired, generic error.
Decisions these frames freeze
- Placement. The step comes right after
POST /signup(201, session issued) and before the check-inbox screen, running in parallel with email verification. Phone verification does need an authenticated session:/verify/phone/startand/verify/statuscallrequireBearer. Signup issues one, so the step can run immediately. Waiting for email verification first would make a working SMS step depend on email, and a user who opens the link on another device would arrive there without a session. See 01. - One exit rule. Every way out of the step goes through
nextAfterPhone(), using freshly re-fetched status: Continue, Skip, a deferral, and every bypass (flag or policy off, SMS unavailable, already verified). The user sees check-inbox only if an email was sent and isn't confirmed yet. If email verification is off, or the link was confirmed in parallel, they go straight into the app. - Skip policy. Both outcomes are designed, with one server switch
verification.phoneAtSignup(Δ). The recommended default isoptional.requiredremoves the user's Skip but still never blocks account creation. Every failure state, including a network error or an unclassified 5xx, offers Continue for now in place of Skip. - Re-prompt at sign-in. Under
required, a flagged redirect after every successful sign-in sends an unverified user to/signup/phone?next=…when SMS is available. See 01. - Honest copy. The screens promise only what
/verify/phonedoes: confirm a contact number. They don't claim it enables text-message sign-in codes or account recovery. SMS MFA enrolment is a separate flow. - No enumeration.
/startreturns 202 for any well-formed number. "Number can't be used" (409) comes only from/confirm, after a correct code, so only the phone's owner ever sees it. - Honest degrade. If
AuthMethods.verification.smsisfalse, the step is never shown. If SMS fails mid-flow (503), the screen says so plainly and offers Continue. There is no dead end anywhere. - No vendor names in the UI. The text-message provider is never mentioned.
Build inventory approve = approve this plan
Flows (orchestrators)
SignupPhoneVerificationFlow→/signup/phonepending
Components
SignupPhoneVerificationFlownewContactVerifyPanelshared w/ mfa-stepup-mobilePhoneNumberFieldnewOtpInputreuseResendCooldownButtonreuse · auth-experienceSignupProgressnewAuthCardreuse · auth-experience
Packages
@fuzefront/auth-ui(extend)- implementation:
packages/auth-ui/src/components/phone-verification/** - consumes
@fuzefront/security-client - consumes
libphonenumber-js(MIT, E.164 + per-country length. Adopted, not hand-rolled)
DS primitives to add first frontend-engineer
OtpInput(44×52 cells). Already named as missing by mfa-management and mfa-stepup-mobile; build it oncePhoneInput/CountryCodeSelect(flag + dial code, 44px target)Alertinfo/warningvariants (if still missing)
Contract binding & deltas
OpenAPI packages/security/openapi.yaml → @fuzefront/security-client · flag
fuzefront.auth.signup-phone-verification (default OFF, builders audience) · endpoints
GET /v1/security/methods, GET /v1/security/verify/status,
POST /v1/security/verify/phone/start, POST /v1/security/verify/phone/resend (new, Δ),
POST /v1/security/verify/phone/confirm.
AuthMethods does advertise SMS availability: verification.sms, derived from
SMS_SERVICE_URL in GET /methods. The step relies on it to decide whether to appear.
The contract today is not enough to render these frames honestly. The deltas below are for
contract-designer → backend-engineer:
- security
POST /verify/phone/confirmmust requirebearerAuthand bind to the session user. Today the route has norequireBearer, and the provider runsusers.where({ phone }).update({ phone_verified: true }), which marks every account with that number as verified. /startmust not writeusers.phonebefore the user proves they have the phone. Today the provider writesusers.phoneand only then awaitssendSmsOtp. It should hold a pending number, promote it on confirm, and accept a confirm body of{ code }. The pending challenge, and sophonePending, is committed only after the SMS provider accepts the send. On 502, 503 or 429 it is rolled back, so a reload never resumes at code entry when no code was sent.- security Every
?nextgoes through onesafeReturnPath()guard, following LoginPage's allowlist approach: a single leading/(never//, never a scheme), same origin, normalized, and on the allowlist of app routes. Anything else goes to the default app. Without it,?next=//evil.exampleis an open redirect straight after sign-in. 202bodyPhoneVerifyStartAck { maskedPhone, expiresInSeconds, resendAfterSeconds }. On a resend,expiresInSecondsis the code's actual remaining lifetime. The provider may re-send the same unexpired code without extending it./starterrors:400 PHONE_INVALID·429 RATE_LIMITED+Retry-After(no limiter exists today) ·502 SMS_SEND_FAILED·503 SMS_UNAVAILABLE. Today these fall through to401 UNKNOWN "Authentication failed", so a failed send looks like a lost session./confirmerrors:422 CODE_INVALID {attemptsRemaining}·422 CODE_EXPIRED·429 TOO_MANY_ATTEMPTS+Retry-After·409 PHONE_UNAVAILABLE(only after a correct code). Today all of these come back as401 NOT_ACTIVE./startreturns 202 for every well-formed number, whether or not another account already uses it.AuthMethods.verification.phoneAtSignup: "off" | "optional" | "required".VerificationStatus.phonePending?: { maskedPhone, expiresInSeconds, resendAfterSeconds }so the step can resume after a reload. Only the masked number ever comes back to the client.- New
POST /verify/phone/resend: session-bound with an empty body. It resends to the pending number the server holds, so Resend still works after a reload, when the client knows only the masked number. ReturnsPhoneVerifyStartAck;404 NO_PENDING_VERIFICATIONsends the user back to enter-phone./startremains the only call that takes a full number. - Confirm-time outage: when sms-service
/sms/verifytimes out or returns non-2xx, the result is503 VERIFY_UNAVAILABLE. It doesn't use an attempt and isn't reported as a wrong code. TodaycheckSmsOtpturns every failure intofalse, and/sms/verifyreturns only a boolean. It should return the provider status, and the client should get a tagged result. - Rate-limit pass-through: sms-service
/sms/sendreturns 429 with noRetry-After, andsendSmsOtpdiscards the response. Both must carryretryAfterSecondsthrough to429 RATE_LIMITED+Retry-Afteron/startand/resend. - Routing: when the step applies, 01-signup's submit goes to
/signup/phone. Otherwise it goes throughnextAfterPhone():/signup/verifyonly if an email is pending, else the app. Behind the flag; no approved frame changes. - Routing: after a successful sign-in in auth-experience's
signinflow, redirect to/signup/phone?next=…whenphoneAtSignup === "required",phoneVerifiedis false and SMS is available. Behind the flag; no approved frame changes.
Reconciliation with existing frames
- mfa-stepup-mobile · 03-confirm-contact (flow
confirm-contact-phone, unapproved). This feature is the sign-up version of the same phone OTP screen. It uses the samedata-panel="confirm-contact"and the same state and action vocabulary (enter-phone · sending · enter-code · error-invalid · error-send · verified;send-code · verify-contact · resend · skip), addsdata-context="signup", and adds the states that frame lacks (expired, locked/429, rate-limited, unavailable, number-unavailable, loading, session, generic). It uses the same OtpInput, but with cells widened to 44×52 so both dimensions meet the 44px tap target; mfa-stepup-mobile's 42px-wide cells should adopt this. The implementation should be oneContactVerifyPanel+ state machine used in both places, not two parallel trees. If this is approved first, mfa-stepup-mobile's 03 should gain these states when it is next revised. - auth-experience (approved). 01-signup, 03-check-inbox and 04-verify-landing are unchanged. Email stays link-click, as mfa-stepup-mobile's reconciliation also decided. This step is placed between 01 and 03.
- account-security / mfa-stepup-mobile 04. After a skip, the reminder is the existing "Unverified" phone chip in the security status view. No new reminder surface is designed here.
Open questions for the owner
- Skip-policy default: these frames assume
optional. - Can one number be used by more than one account? If yes, the 409 state goes away.
- Package home of the shared
ContactVerifyPanel:@fuzefront/auth-ui(assumed here), consumed by@fuzefront/account-security-ui? - Gate coverage: auth-experience's approved flows claim
packages/auth-ui/src/**, sogate-frames-firstwould count this feature's code as approved through them and would not block it before these frames are approved. Fixing this means narrowing auth-experience'simplementation.paths, which changes its stamp and needs re-approval, or making the gate prefer the most specific claim.
Status: ● awaiting review. The flow is
approved: false. No UI, tests, or deploy exist yet; merging the approved frames is the gate that starts them.
Re-stamp after any edit: node scripts/stamp-frames.mjs --write --feature signup-phone-verification.