Skip to content

Phase C — Account recovery for passkey-only users, plus parked program items #112

Description

@yahyafakhroji

Sub-issue of datum-cloud/enhancements#738. Phase C of the passkey program.

Scope

Answer "what happens when all passkeys are lost" without falling back to passwords, and
hold the items the team deliberately parked. The recovery design must be signed off
before Phase B GA; the implementation can follow.

What's included

Recovery

  • C1 — Recovery design sign-off. Email-OTP to a short-lived privileged session whose only permitted action is enrolling a new passkey; admin-sent registration link as backstop; recovery codes deferred. Required before Phase B GA. No longer gates Phase B GA — 2026-09-07. Phase B's scope is the signup flow and only that; a Phase C design cannot gate a Phase B release. C1's priority is now set by how long a user without a usable passkey has to wait, not by a release date.
  • C2 — Interim recovery ships with Phase B: email-OTP login as a permanent fallback. SUPERSEDED 2026-09-07 — decided, not deferred. Email-OTP is a recovery ceremony, never a login method. Login is always passkey. A permanent OTP login fallback moves the account's real security floor from the passkey to the email inbox — the thing passwordless exists to avoid — so the interim is skipped and C1/C3's enroll-only session is the whole answer. #128 hides both /login entry points behind one flag. The /error landing for an otpEmail-only account is intended, not a defect — do not "fix" it by re-exposing OTP sign-in. The machinery stays intact (app/resources/otp/, intent=email-link, /login/verify/email) and is what C3 builds on.
  • C3 — Dedicated recovery flow + emails/user-account-recovery.tsx. Three classes to serve (named 2026-09-07):
    • (a) Lost authenticator — device wiped, lost or stolen. Zitadel still holds the passkey record; the human does not. The largest class after GA, and the only one that hits a user who did everything right.
    • (b) Verified but never enrolled — abandoned at the passkey step. Same population as C9.
    • (c) Admin backstop — a support-triggered passkey registration link for what self-serve cannot reach. Net-new work: nothing in our code sends one today; zitadel-provider exposes ListPasskeys and nothing else.
    • Not a class: removing a last passkey — the server-side guard in passkeys.service.ts:141 blocks it.
  • C9 — The stranded class: verified email, no passkey. zp#134, gap 2. AbandonedUserGC.isAbandoned returns false the moment IsEmailVerified is true and never consults passkey state, so an account that verifies its email and then abandons at the passkey step can neither authenticate nor be collected — stranded permanently. It belongs to this phase because the fix is a C1 question: if recovery makes these accounts reachable, deleting them at the 30-day cutoff destroys an account the user could still claim; if it does not, collection is the only outcome left. So the GC predicate — no verified email or no usable credential — cannot be settled before C1 signs off. The issue's option B (hold the Zitadel user inactive until signup completes) is a C1 review item, not a separate track: it carries an untested deadlock (can a deactivated user still verify and enroll?) and reuses the state platformaccess_controller.go already spends on policy suspension. Live today, not theoretical: #128 hides email-OTP sign-in, and an account whose only enrolled method is otpEmail — exactly this class — reaches /error.

Parked — need a team decision

  • True usernameless sign-in (resolving a random credential ID to a user) — blocked on Zitadel #8899. The hint-based fast path shipped in Phase A; this is the remaining capability.
  • Org-level auth policy (require passkey/MFA per org)
  • Adoption and health dashboards (method mix, enrollment funnel, ceremony failure rate)
  • Bot-protection tooling choice — decision deadline: before Phase B GA. Placement fixed: signup and recovery forms only.
  • Playwright virtual-authenticator E2E
  • Passkey last-used timestamps (created-at shipped in Phase A)

Status

Not started. C1 needs scheduling — it blocks Phase B GA, not Phase B start.

Updated 2026-09-07 — the phase boundary is now explicit. Phase B ships the signup flow; Phase C owns every recovery path and every abandoned-account decision. Neither gates the other. Phase B's operating assumption is that a user who signs up by email enrols a passkey; a user who ends up without a usable one reaches /error and waits for this phase.

That wait is the accepted residual and it is what sets this phase's priority. C6 resolved via reCAPTCHA v3 (#127); C2 superseded; C1 retired as a release gate; C9 added from the Phase B review and still downstream of C1 — C2's removal is precisely what keeps that dependency real, since with no OTP login fallback a verified-no-passkey account genuinely is unreachable until C3 ships.

Gap 1 of zp#134 — the abandoned-account GC never leaving dry-run — is deliberately not in this phase. It is Phase B Lane F F3 plus §5 rollout step 8 (--abandoned-account-gc-dry-run=false); it is unwired because F3 has not merged, not because it is unowned.

Updated 2026-09-14 — implementation complete; in team review. All lanes are built, machine-reviewed and CI-green: 19 PRs across six repos — 2 merged, 12 open for review, 4 drafts held for the rollout, 1 closed (superseded by a stack). Two changes came out of review: the email-verified signal became a typed status field status.emailVerification (Verified | Unverified, empty = not yet synced) instead of a condition, landed across milo, zitadel-provider and staff-portal; and the recovery mail copy was simplified per content review (email-templates#108, merged, on staging since 09-11). Merge order (roadmap §5a): auth-ui #130#134 → milo #782 (+ tag) → zitadel-provider #141#136#137#138#139#140 → #142 (#143 standalone) → milo #784; then the infra drafts and staff-portal for the staging rollout; production per §5b.

Repo PR What Status
email-templates #108 Recovery mails: self-serve + support-sent (shared body) ✅ Merged 09-11, on staging
auth-ui #131 Dependency bumps for bun audit ✅ Merged 09-09
auth-ui #130 C10: refuse hidden email-OTP sign-in everywhere 🟢 Open, CI green — needs review
auth-ui #134 Lane D: self-serve recovery (/recover, /recover/complete) 🟢 Open, stacked on #130, CI green
milo #782 status.emailVerification field, PasskeyRegistrationLink type, roles 🟢 Open, CI green — needs review; tag on merge
milo #784 C11 reader: waitlist mails wait for a verified address 🟢 Open, stacked on #782, CI green
zitadel-provider #141 milo pin (Phase C API commit) 🟢 Open, CI green — re-approval needed after the re-pin
zitadel-provider #136 Zitadel API: CreatePasskeyRegistrationLink, ListAuthMethodTypes ✅ Approved, CI green
zitadel-provider #137 C11 writer: the emailVerification field at provisioning, email events, sweeper 🟡 Reworked to the field per review — awaiting re-review
zitadel-provider #138 /v1/email/recovery webhook 🟢 Open, CI green
zitadel-provider #139 PasskeyRegistrationLink REST (create-only, flagged) 🟢 Open, CI green
zitadel-provider #140 C9 gauge: verified accounts with no usable credential 🟢 Open, CI green
zitadel-provider #142 Bundle flags + runbook 🟢 Open, CI green
zitadel-provider #143 Passkey-removed mail: default template + flag (Phase B leftover) 🟢 Open, CI green — needs review
zitadel-provider #135 Original Lane A PR ⚪ Closed — split into #141…#142
staff-portal #664 Lane S: send recovery links, passkey count + verified badge 📝 Draft, CI green — client regen once milo #782 is on staging
infra #5070 Lane F: staging wiring (flags off) 📝 Draft, CI green
infra #5071 F3: enable account recovery on staging 📝 Draft, stacked on #5070
infra #5072 F4: enable support-sent recovery links on staging 📝 Draft, stacked on #5071

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions