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.
01 Create account
POST /signup → 201, session issued. Email verification starts here too (POST /verify/email/start).
Verify phone · /signup/phone
Enter phone → code → verified. Shown only when the flag is ON, the policy isn't off, and SMS is available.
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.
04 Verify link landing
Unchanged, and independent of the phone step: either one can finish first.
Why here, and why in parallel
- The session exists already.
/verify/phone/startand/verify/statuscallrequireBearerinbackend/security/src/routes/security.ts. Signup returns a session, so the step needs no extra sign-in. If it ran after email verification, a user who opens the email link on another device would land there with no session and would have to sign in again. - Email verification doesn't block, so the phone step shouldn't wait for it. The approved check-inbox screen already says "you can keep working". If the phone step waited behind the email link, a slow mail service would hold up SMS too, even though SMS doesn't depend on email at all.
- Phone first, inbox screen second. The user has their phone in hand and the code arrives in seconds. By the time they finish, the email link is usually waiting. If the order were reversed, the user would sit on the "check your inbox" screen first and then have to come back for the phone step.
- Approved flows stay unchanged. 01-signup's submit now routes to
/signup/phoneinstead of/signup/verifywhen the step applies. No approved frame is edited. The route change is a delta noted in the manifest, and it only happens behind the flag.
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:
- Refresh with a short timeout (about 3 s). If it succeeds, use it.
- If it fails or times out, use the snapshot of
GET /methods+GET /verify/statusloaded when the step opened. The step never renders interactive controls without it (08 · loading), so this snapshot always exists. - 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 exit | Destination | data-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 untrue | The app: ?next=, else the default app | app |
emailVerified === true (confirmed in parallel) | The app: ?next=, else the default app | app |
Routing after POST /signup → 201 (decided client-side from GET /methods + GET /verify/status)
| Condition | Goes to | Frame |
|---|---|---|
Flag fuzefront.auth.signup-phone-verification OFF, or verification.phoneAtSignup = "off" (Δ) | nextAfterPhone(). The step doesn't exist | auth-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 === true | nextAfterPhone() (skip silently) | 08 · already-verified |
VerificationStatus.phonePending present (Δ, e.g. after a reload) | /signup/phone resumes at enter code | 08 · resumed |
| Otherwise | /signup/phone → enter phone | 02 |
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:
| Condition | Goes 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.
- Decode once. A value that still contains
%2F/%5Cafter one decode is rejected (no double-encoding tricks). The one exception is the single nestednextinside a/signup/phonetarget, which is decoded and checked again with this same guard, at most one level deep. - It must start with exactly one
/. Rejected://…,/\…,\…, any scheme (https:,javascript:,data:), and control characters or whitespace. - Resolve with
new URL(next, location.origin)and requireurl.origin === location.origin. Then use onlypathname + search, normalized (no./..segments, no fragment). - 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 value | Result |
|---|---|
/apps/plan | Allowed: /apps/plan |
/signup/phone?next=%2Fapps%2Fplan | Allowed. The inner /apps/plan is checked again by the same guard |
//evil.example | Rejected → default app |
https://evil.example/apps/plan, javascript:alert(1) | Rejected → default app |
/\evil.example | Rejected → default app |
/admin/impersonate | Not on the allowlist → default app |
/signup/phone?next=%2F%2Fevil.example | The outer path is allowed, the inner one is rejected: the user goes to /signup/phone and its exits use the default app |