← All frames · Frame 01 · Placement in signup

Where phone verification sits in signup

The phone step runs immediately after the account is created, in parallel with email verification — not after it. Signup (POST /v1/security/signup → 201 LoginResponse) already establishes a session, and every phone-verify endpoint is a session-bound call, so the step can run the moment signup returns. The email link is already on its way while the user enters their phone code.

approved · auth-experience

01 Create account

POST /signup → 201, session issued. Email verification starts here too (POST /verify/email/start).

new · this feature

Verify phone · /signup/phone

Enter phone → code → verified. Shown only when the flag is ON, the policy isn't off, and SMS is available.

approved · auth-experience

03 Check your inbox

Unchanged. Reached only when an email was sent and isn't confirmed yet (nextAfterPhone()). Otherwise the user goes straight into the app.

approved · auth-experience

04 Verify link landing

Unchanged, and independent of the phone step: either one can finish first.

Why here, and why in parallel

One exit rule: nextAfterPhone()

Every way out of the phone step goes through the same check: Continue after verifying, Skip, Continue-without-phone (deferral), and every bypass in the table below. It tries a fresh GET /verify/status, because the email link may have been confirmed in another tab while the phone step was open. An exit never waits on that call, which matters most during an outage, when Skip and Continue-for-now are the user's way out:

  1. Refresh with a short timeout (about 3 s). If it succeeds, use it.
  2. If it fails or times out, use the snapshot of GET /methods + GET /verify/status loaded when the step opened. The step never renders interactive controls without it (08 · loading), so this snapshot always exists.
  3. Navigate straight away. A stale snapshot can only mean showing check-inbox for an email that was just confirmed, which is harmless (that screen says "you can keep working"). It can never trap the user.
Condition at exitDestinationdata-next
AuthMethods.verification.email === true and VerificationStatus.emailVerified === false/signup/verify (auth-experience 03, check inbox)verify-email
verification.email === false: no email was sent, so the inbox screen would be untrueThe app: ?next=, else the default appapp
emailVerified === true (confirmed in parallel)The app: ?next=, else the default appapp

Routing after POST /signup → 201 (decided client-side from GET /methods + GET /verify/status)

ConditionGoes toFrame
Flag fuzefront.auth.signup-phone-verification OFF, or verification.phoneAtSignup = "off" (Δ)nextAfterPhone(). The step doesn't existauth-experience 03 or the app
AuthMethods.verification.sms === false (no SMS_SERVICE_URL)nextAfterPhone(). The step is not shown, because we don't open a flow we can't finish. Under required, the sign-in redirect below re-prompts once SMS is available.08 · sms-off-preflight
VerificationStatus.phoneVerified === truenextAfterPhone() (skip silently)08 · already-verified
VerificationStatus.phonePending present (Δ, e.g. after a reload)/signup/phone resumes at enter code08 · resumed
Otherwise/signup/phone → enter phone02

Re-prompt at sign-in (flagged): how required keeps its promise

After any successful sign-in (auth-experience SignInFlow, including after the MFA step-up), and before navigating to ?next=, the client checks GET /methods + GET /verify/status:

ConditionGoes to
Flag ON and phoneAtSignup === "required" and phoneVerified === false and verification.sms === true/signup/phone?next=<original next>. The same flow and frames, with the signup progress rail hidden. Every exit returns to ?next=.
The sign-in ?next= already points at this flow (its path is /signup/phone), e.g. after a session expired mid-step (08)Go to that next as is. It is never re-wrapped, so /signup/phone?next=/signup/phone?… can't arise and there is no loop. The nested downstream target inside it is kept.
Anything else: optional or off, flag OFF, already verified, or SMS unavailable?next= as today. Under optional the only reminder is the "Unverified" chip in Security.

Re-prompting happens once per sign-in, not on every navigation: a deferral during that sign-in (503 or lockout) holds until the next sign-in. This is a frontend routing delta on the approved signin flow, behind the same flag. No approved frame changes.

Return-path safety: ?next is never an open redirect

Every next this flow reads or writes goes through one shared guard, safeReturnPath(next). That covers the sign-in → /signup/phone?next=… re-prompt, the session-expired → sign-in → back hop, and every nextAfterPhone() exit. The guard follows the existing LoginPage approach (frontend/src/pages/LoginPage.tsx:103-114): an allowlist of same-origin paths, never a generic redirect. Any value that fails a check is discarded, and the user lands on the default app. Nothing is reflected, and no error is shown.

  1. Decode once. A value that still contains %2F/%5C after one decode is rejected (no double-encoding tricks). The one exception is the single nested next inside a /signup/phone target, which is decoded and checked again with this same guard, at most one level deep.
  2. It must start with exactly one /. Rejected: //…, /\…, \…, any scheme (https:, javascript:, data:), and control characters or whitespace.
  3. Resolve with new URL(next, location.origin) and require url.origin === location.origin. Then use only pathname + search, normalized (no ./.. segments, no fragment).
  4. The normalized pathname must match the allowlist of app route prefixes, held in one module next to LoginPage's: e.g. /apps/, /account/, /signup/phone, /fuzepicker/mentions. A route that isn't listed falls back to the default app.
next valueResult
/apps/planAllowed: /apps/plan
/signup/phone?next=%2Fapps%2FplanAllowed. The inner /apps/plan is checked again by the same guard
//evil.exampleRejected → default app
https://evil.example/apps/plan, javascript:alert(1)Rejected → default app
/\evil.exampleRejected → default app
/admin/impersonateNot on the allowlist → default app
/signup/phone?next=%2F%2Fevil.exampleThe outer path is allowed, the inner one is rejected: the user goes to /signup/phone and its exits use the default app
Review · signup-phone-verification approve or reject each flow ↓
signup-phonependingApproveReject