Passkeys & WebAuthn 2026 — Replacing Passwords with FIDO2, Platform Authenticators, and Phishing-resistant Auth on .NET 10 and Vue

Posted on: 4/17/2026 8:11:33 AM

1. Why 2026 is finally the year passwords really start dying

For two decades, passwords have been the "thin layer" every system complains about yet no one has been able to replace. Users pick weak passwords, reuse them across services, store them in Excel files; the security team demands special characters, 90-day rotations, and SMS OTP — and we still get credential stuffing, phishing, SIM-swap, and account takeovers. When Apple, Google, and Microsoft jointly endorsed passkeys in 2022, most engineers still treated it as "yet another convenience feature". By 2026 the story has changed: major OSes sync passkeys by default through cloud keychains, browsers have Conditional UI turned on across the board, and large platforms (Google, Apple, Microsoft, GitHub, PayPal, Shopify, Amazon, European banks) now let people sign in without knowing a password. In Q1 2026 the FIDO Alliance announced that more than 20 billion passkeys had been created on user devices — surpassing the total number of accounts that previously enabled SMS 2FA.

For backend and frontend engineers, moving to passkeys isn't "just adding another login method" — it's a rare chance to rebuild the whole authentication flow around phishing-resistance, eliminate the "send the password via email" category entirely, and cut support costs for "forgot password" incidents by roughly 60-70%. This article is a practitioner's handbook for teams evaluating passkeys on a .NET 10 (Fido2NetLib) + Vue 3.6 (SimpleWebAuthn browser) stack: from algorithms and protocol flows to implementation details, a migration strategy for user bases with existing passwords, production monitoring, and a go-live checklist.

20B+active passkeys worldwide (Q1 2026, FIDO Alliance)
0%phishing success rate when passkeys are used
-75%average sign-in time vs password + OTP
higher completed sign-in rate (Shopify, 2025 report)

Three strategic questions before migrating

Are you willing to support both password and passkey flows for at least 12-18 months (very few products can "flip the switch")? Does your product allow binding an account to a device/platform authenticator, or do you serve many shared kiosks, industrial machines, or groups that share accounts? Does your support team have a process to handle "lost every device" cases with a safe recovery path (not falling back to phishable email OTP)? Three "yes" answers mean you're ready; a "no" on any of them should be solved before a broad rollout.

2. The evolution — From passwords to passkeys

WebAuthn didn't appear out of nowhere. It's the result of more than a decade of trial and error: HOTP/TOTP, push notification OTP, smart cards, U2F security keys, FIDO UAF, and finally FIDO2. Knowing the history helps you see why some spec concepts look complex — they carry the scars of specific security failures.

2004 — RSA SecurID tokens go mainstream
Hardware tokens generate 6-digit time-based OTPs. Strong against replay but useless against real-time phishing, and expensive at scale.
2011 — TOTP standardized as RFC 6238
Google Authenticator popularized soft tokens. Free — but still not origin-bound, so phishing sites can just ask for the OTP and get it.
2014 — FIDO U2F released
First-generation Yubikey, Google Titan key. For the first time, browsers could sign challenges with hardware and check origin — real-time phishing eliminated in theory.
2019 — W3C WebAuthn Level 1 becomes a recommendation
U2F generalized, plus user verification and platform authenticators. The real foundation for passkeys.
Q2 2022 — Apple, Google, Microsoft announce passkeys
A shift from "WebAuthn for 2FA" to "WebAuthn to replace passwords". The key change: credentials now sync via iCloud Keychain, Google Password Manager, and Microsoft Authenticator.
Q2 2023 — WebAuthn Level 3 draft + Conditional UI
Autofill-style UX: the browser suggests passkeys directly in the email/username field. The experience resembles traditional password autocomplete, so users aren't jolted.
Q4 2024 — CTAP 2.2 and stable hybrid transport
Cross-device authentication via QR + Bluetooth LE reaches production. You can sign in on a machine without its own passkeys by using your phone.
2026 — Fido2NetLib 5.x + SimpleWebAuthn 11.x
Both dominant libraries now fully support Level 3, Conditional UI, trusted attestation via FIDO Metadata Service v3, and integrate cleanly with .NET 10 Minimal API and Vue 3.6's composition script setup.

3. The big picture — Relying Party, Authenticator, and Client

Before reading code, keep three roles firmly in mind. Miss one and reading the spec becomes exhausting and you'll easily mis-implement the verify step.

  • Relying Party (RP) — your backend. When your product lives at example.com, the RP ID defaults to that domain. The RP is the party that issues challenges and verifies returned signatures.
  • Authenticator — where the private key lives. Two types: platform authenticator (Touch ID/Face ID on a Mac, Windows Hello, Android biometrics) and roaming authenticator (Yubikey, Titan key, or a phone connected via hybrid transport).
  • Client — the browser (Chrome, Safari, Firefox, Edge) or the mobile OS. The client implements the Web Authentication API and bridges your JavaScript and the authenticator.
flowchart LR
    U[User] --> C[Browser / Client]
    C <-- WebAuthn API --> A[Authenticator
Touch ID / Yubikey] C <-- HTTPS --> RP[Relying Party
.NET 10 API] RP <-- FMDS v3 --> FM[FIDO Metadata Service
Attestation trust] RP --> DB[(Credential Store
Postgres / SQL)]
The four components and communication channels in a standard 2026 passkey system.

The private key never leaves the authenticator. This has a major consequence: the backend can't "take it out and hand it to another device" the way it can with a password hash — each device/passkey is an independent credential with its own public key, even for the same account. Internalizing this is the first step to designing the storage schema correctly.

4. The Registration flow — How "create a passkey" works

Registration is the first flow you implement and the one most commonly done wrong. Once the principles are clear, the technical steps follow.

sequenceDiagram
    participant U as User
    participant V as Vue frontend
    participant R as .NET RP API
    participant B as Browser
    participant A as Authenticator

    U->>V: Click "Create passkey"
    V->>R: POST /webauthn/register/options
    R->>R: Generate 32-byte challenge,
store in session/cache R-->>V: PublicKeyCredentialCreationOptions V->>B: navigator.credentials.create(options) B->>A: CTAP makeCredential A->>U: Verify biometric / PIN U-->>A: Touch / Face / PIN A-->>B: attestationObject + clientDataJSON B-->>V: PublicKeyCredential V->>R: POST /webauthn/register/verify R->>R: Verify signature, origin, challenge,
RP ID hash, user handle R->>DB: Save CredentialId + PublicKey
+ SignCount + AAGUID R-->>V: 201 Created
Passkey registration with attestation — challenge prevents replay, RP ID prevents phishing.

There are five things the RP must check — none are optional:

  1. Challenge match: the challenge in clientDataJSON must exactly equal the value you generated and stored (in session/Redis/memory cache with a ~5-minute TTL).
  2. Origin match: the origin in clientDataJSON must be on your allowed list (usually the main domain; for SPAs with dedicated subdomains, configure carefully).
  3. RP ID hash: the first 32 bytes of authenticatorData are the SHA-256 of the RP ID. If they don't match — reject. This is the anti-phishing layer that "can't be bypassed with social engineering".
  4. UP/UV flags: the User Presence (UP) bit must be on; for strongly-authenticated accounts, the User Verification (UV) bit should be on too (enforcing biometric/PIN at registration).
  5. Attestation: if you require direct attestation, verify the X.509 chain against FIDO Metadata Service v3 so you know the AAGUID corresponds to a certified authenticator.

5. The Authentication flow — "Sign in with a passkey"

Authentication is lighter than registration: no attestation, just challenge + signature. But there's a trap everyone hits: forgetting to check the sign counter, or implementing Conditional UI incorrectly.

sequenceDiagram
    participant U as User
    participant V as Vue frontend
    participant R as .NET RP API
    participant B as Browser
    participant A as Authenticator

    U->>V: Open login page
    V->>R: POST /webauthn/assert/options
    R-->>V: PublicKeyCredentialRequestOptions
    V->>B: navigator.credentials.get({mediation: "conditional"})
    B->>U: Show passkey autofill in the email field
    U-->>B: Pick a passkey, touch / face
    B->>A: CTAP getAssertion
    A-->>B: signature + authenticatorData + clientDataJSON
    B-->>V: AssertionResponse
    V->>R: POST /webauthn/assert/verify
    R->>R: Load public key by credentialId,
verify signature,
check signCount increases R-->>V: 200 OK + session cookie / JWT V->>U: Enter the app
Authentication with Conditional UI — the user doesn't type an email; the browser offers passkeys right in autofill.

The key difference versus a "tap the button to open a modal" login: the browser handles the picker with familiar UI (an autofill dropdown), and if no passkey matches, it stays silent — the user can still type their email and password. That way you don't break UX for users who haven't migrated.

Be careful with the sign counter

Many cloud-synced passkeys (Apple, Google) always return signCount = 0. If you strictly enforce "must always increase", you'll reject legitimate cloud passkeys. The right rule: only reject when the authenticator previously reported a sign count greater than 0 and now reports a smaller value. For authenticators that always report 0, accept and skip the comparison.

6. Why passkeys resist phishing — an analysis at the protocol layer

Many articles say "passkeys resist phishing" without explaining why. The short answer: the signature is bound to the RP ID, and the authenticator only signs when the client provides the same RP ID used at registration. Compare with OTP to see it clearly.

Attack scenarioPassword + SMS OTPPassword + TOTPPasskey WebAuthn
Phishing site that looks identicalSuccessful (user types both password and OTP)Successful (phisher relays OTP in 30 s)Fails (origin/RP ID mismatch)
SIM-swap to receive OTPsSuccessfulNot applicableNot applicable
Malware reading clipboard/keylogSuccessfulSuccessful (reads the code on copy)Fails (private key never leaves the authenticator)
Credential stuffing from leaksSuccessfulSuccessful (if 2FA off)Fails (no password to stuff)
Real-time MiTM with a reverse proxySuccessfulSuccessfulFails (authenticator checks origin)

The last column is why the FIDO Alliance calls passkeys "phishing-resistant by design": there's no user action a phisher can "borrow". All the defenses live in the protocol, not in whether the user stays alert.

7. Platform vs Roaming authenticators

Picking the wrong authenticator type for your product is the top reason adoption stays low. A simple rule to decide:

CriterionPlatform Authenticator
(Touch ID, Windows Hello, Android bio)
Roaming Authenticator
(Yubikey, Titan, Hybrid via mobile)
User costFree (already built in)Must buy hardware or use a phone
Cross-device syncYes, within the same ecosystem (iCloud, Google)No; or via hybrid QR/BLE
Losing the deviceRecovery via the cloud (if sync is enabled)Total loss without a backup key
Resident credentialsAlways resident (discoverable)Depends on device; Yubikey 5 NFC has limited slots
Best-fit use caseConsumer web, mobile apps, B2CEnterprise, admin root, high-compliance

For consumer B2C products, prefer a platform authenticator (authenticatorAttachment: "platform" during registration) and use roaming keys as a secondary option for users with high security needs. For enterprise, many teams require roaming keys for compliance reasons — in that case, organize registration of two keys (primary + backup) per employee.

8. Passkey Sync — How cloud keychains work

The biggest debate between a strict security team and a product team is passkey sync. Purely theoretically, passkeys should be device-bound (hard-bound to a single device). But 2022 proved the reality: without sync, if a user loses their phone, they lose the account — a terrible experience. So Apple, Google, and Microsoft all ship synced passkeys.

flowchart TB
    subgraph DeviceA["User's iPhone"]
        PA[Passkey A
Secure Enclave] end subgraph DeviceB["User's MacBook"] PB[Passkey A copy
Secure Enclave] end subgraph iCloud["iCloud Keychain"] Sync[E2EE sync tunnel
User recovery key required] end PA -- wrapped with E2EE --> Sync Sync -- unwrap on device --> PB RP[Relying Party backend] <-- same credentialId --> PA RP <-- same credentialId --> PB
Apple syncs passkeys through iCloud Keychain end-to-end encrypted; from the RP's view, both devices share the same credentialId and public key.

From the RP's view, a synced passkey is one credential that appears on multiple devices — not one credential per device. That simplifies the storage schema but also means that if a user loses control of their iCloud, the attacker can sign in. This is why highly sensitive accounts (banking, admin tenant) should still use device-bound or roaming keys rather than rely solely on synced passkeys.

9. Designing the credential-storage schema on the RP

Getting columns right from day one is the foundation; getting them wrong leads to painful migrations. Here's the minimal schema you should ship on day one:

CREATE TABLE WebAuthnCredential (
    Id               BIGINT IDENTITY PRIMARY KEY,
    UserId           BIGINT       NOT NULL,
    CredentialId     VARBINARY(256) NOT NULL UNIQUE,
    PublicKey        VARBINARY(512) NOT NULL,          -- COSE key
    SignCount        BIGINT       NOT NULL DEFAULT 0,
    AaGuid           UNIQUEIDENTIFIER NULL,            -- identifies authenticator model
    AuthenticatorAttachment VARCHAR(16) NULL,          -- "platform" | "cross-platform"
    Transports       VARCHAR(64)  NULL,                -- "internal,hybrid,nfc,usb"
    IsBackupEligible BIT          NOT NULL DEFAULT 0,  -- BE flag
    IsBackedUp       BIT          NOT NULL DEFAULT 0,  -- BS flag (synced passkey)
    UserVerified     BIT          NOT NULL DEFAULT 0,
    Nickname         NVARCHAR(100) NULL,               -- user label: "Tu's iPhone"
    CreatedAt        DATETIME2    NOT NULL DEFAULT SYSUTCDATETIME(),
    LastUsedAt       DATETIME2    NULL
);
CREATE INDEX IX_WebAuthnCredential_UserId ON WebAuthnCredential(UserId);

Distinguishing BE and BS

BE (Backup Eligible) = the credential can be synced; BS (Backup State) = the credential is actually synced. A user may register a passkey on an iPhone without iCloud Keychain enabled — then BE=1, BS=0. Storing both in the DB lets you show the right UI ("this passkey has a backup") and tune security policy (e.g. admin accounts must be BE=0, device-bound).

10. Implementation on .NET 10 with Fido2NetLib 5.x

Fido2NetLib is the de-facto community standard for .NET, maintained by Anders Abel and his team. Version 5.x now supports .NET 10, WebAuthn Level 3, and attestation parsers for every format (packed, tpm, android-key, android-safetynet, apple-appattest, fido-u2f, none).

// Program.cs — Minimal API setup for .NET 10
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFido2(options =>
{
    options.ServerName         = "AnhTu Demo";
    options.ServerDomain       = builder.Configuration["WebAuthn:RpId"];   // "anhtu.dev"
    options.Origins            = new HashSet<string> { "https://anhtu.dev" };
    options.TimestampDriftTolerance = 300_000;                             // 5 minutes
    options.MDSCacheDirPath    = Path.Combine(AppContext.BaseDirectory, "mds");
})
.AddCachedMetadataService(mds =>
{
    mds.AddFidoMetadataRepository();
});

builder.Services.AddScoped<ICredentialStore, EfCredentialStore>();
builder.Services.AddDistributedMemoryCache();   // store challenges temporarily

var app = builder.Build();

app.MapPost("/webauthn/register/options", async (
    HttpContext ctx, IFido2 fido2, ICredentialStore store) =>
{
    var user = await store.GetCurrentUserAsync(ctx);
    var existing = await store.GetCredentialsAsync(user.Id);

    var options = fido2.RequestNewCredential(new RequestNewCredentialParams
    {
        User = new Fido2User { Id = user.Id.ToBytes(), Name = user.Email, DisplayName = user.FullName },
        ExcludeCredentials = existing.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)).ToList(),
        AuthenticatorSelection = new AuthenticatorSelection
        {
            ResidentKey            = ResidentKeyRequirement.Required,
            UserVerification       = UserVerificationRequirement.Required,
            AuthenticatorAttachment = AuthenticatorAttachment.Platform
        },
        AttestationPreference = AttestationConveyancePreference.Direct
    });

    ctx.Session.SetString("fido2.register", JsonSerializer.Serialize(options));
    return Results.Ok(options);
});

app.MapPost("/webauthn/register/verify", async (
    HttpContext ctx, AuthenticatorAttestationRawResponse raw,
    IFido2 fido2, ICredentialStore store) =>
{
    var options = JsonSerializer.Deserialize<CredentialCreateOptions>(
        ctx.Session.GetString("fido2.register")!)!;

    var result = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
    {
        AttestationResponse = raw,
        OriginalOptions     = options,
        IsCredentialIdUniqueToUser = async (args, _) =>
            !await store.CredentialIdExistsAsync(args.CredentialId)
    });

    await store.AddAsync(new WebAuthnCredential
    {
        UserId          = ctx.GetCurrentUserId(),
        CredentialId    = result.Result!.CredentialId,
        PublicKey       = result.Result.PublicKey,
        SignCount       = result.Result.SignCount,
        AaGuid          = result.Result.AaGuid,
        IsBackupEligible = result.Result.BackupEligible,
        IsBackedUp      = result.Result.BackupState,
        UserVerified    = true,
        Transports      = string.Join(',', result.Result.Transports ?? Array.Empty<string>())
    });

    return Results.Created();
});

app.Run();

Authentication is analogous — use fido2.GetAssertionOptions(...) and fido2.MakeAssertionAsync(...). Remember: when using Conditional UI, return an empty allowCredentials list and let the authenticator pick a resident credential — that's how users avoid typing a username.

Don't forget to protect /register/options

That endpoint creates a challenge and binds it to the current user — it must sit behind an auth layer (e.g. the user logged in via email + magic link) or behind CAPTCHA + rate limiting. If you leave it fully open, attackers can spam-challenges, burn resources, and probe whether a user exists.

11. The Vue 3.6 frontend with SimpleWebAuthn browser

On the frontend, @simplewebauthn/browser saves you from base64url encode/decode (a quiet source of bugs). The Vue composable below leverages script setup and defineModel from 3.6:

// composables/usePasskey.ts
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
  browserSupportsWebAuthnAutofill
} from '@simplewebauthn/browser'
import { ref } from 'vue'

export function usePasskey() {
  const isSupported = ref(browserSupportsWebAuthn())
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  async function register() {
    isLoading.value = true
    error.value = null
    try {
      const options = await $fetch('/api/webauthn/register/options', { method: 'POST' })
      const attResp = await startRegistration({ optionsJSON: options })
      await $fetch('/api/webauthn/register/verify', { method: 'POST', body: attResp })
    } catch (e: any) {
      error.value = e?.message ?? 'Passkey creation failed'
    } finally {
      isLoading.value = false
    }
  }

  async function signInConditional(onSuccess: () => void) {
    if (!(await browserSupportsWebAuthnAutofill())) return
    const options = await $fetch('/api/webauthn/assert/options', { method: 'POST' })
    const assResp = await startAuthentication({
      optionsJSON: options,
      useBrowserAutofill: true
    })
    await $fetch('/api/webauthn/assert/verify', { method: 'POST', body: assResp })
    onSuccess()
  }

  return { isSupported, isLoading, error, register, signInConditional }
}

In the login template, just set autocomplete="username webauthn" on the email input and the browser will suggest passkeys automatically. Call signInConditional inside onMounted to enable Conditional UI right when the page loads:

<script setup lang="ts">
import { onMounted } from 'vue'
import { usePasskey } from '@/composables/usePasskey'
const { signInConditional } = usePasskey()
const router = useRouter()
onMounted(() => signInConditional(() => router.push('/app')))
</script>

<template>
  <form @submit.prevent="loginPassword">
    <input v-model="email" autocomplete="username webauthn"
           placeholder="Email" />
    <input v-model="password" type="password"
           autocomplete="current-password" placeholder="Password (optional)" />
    <button type="submit">Sign in</button>
  </form>
</template>

12. Migration strategy — From passwords to passkeys

Few products are brave enough to "flip the switch" and move every user to passkeys at once. The most reliable plan is a three-phase migration over 9-15 months.

flowchart LR
    subgraph Phase1["Phase 1: Coexist"]
        P1[Password + OTP
remain primary] --> Offer[Offer to create a passkey
after successful login] end subgraph Phase2["Phase 2: Passkey preferred"] Offer --> Preferred[Passkey preferred
Password moved into Advanced] end subgraph Phase3["Phase 3: Passwordless by default"] Preferred --> Default[Passkey only +
magic-link recovery
Password fully retired] end
Three-phase migration — each phase has clear metrics and conditions for advancing.
  • Phase 1 (3-6 months): invite users to create a passkey after a successful login, when changing password, or when enabling 2FA. Exit metric: 30% of MAU have at least one passkey.
  • Phase 2 (3-6 months): Conditional UI on by default; the "sign in with password" button drops to an "Other options" menu. Exit metric: 60% of MAU used a passkey at last login.
  • Phase 3 (3 months): new accounts must create a passkey. Older users without a passkey are prompted to create one after 3 logins. Password stays for recovery only, with step-up (email + one-time code) every time it's used.

Strictly forbidden: SMS OTP fallback

Once you move to passkeys, don't keep SMS OTP as recovery — it drops your security back to the weakest link (SIM-swap). Safe recovery: a magic link via a verified email with short expiry; or require the user to register at least two passkeys (e.g. one on the phone, one on the laptop) — lose this device, use the other.

13. Observability — Metrics and traces you need

A healthy passkey production system needs visibility into the metrics below; each can tell you "here's where the system is broken":

MetricExpected thresholdWhat a deviation means
Registration success rate> 95%Conditional UI may be mis-enabled, or attestation policy too strict
Authentication success rate> 98%Sign counter regression, origin mismatch, or stale metadata service cache
Conditional UI show rate> 80% of supporting browsersWrong autocomplete attribute or missing browserSupportsWebAuthnAutofill call
Avg passkeys per user> 1.5Users haven't added a backup passkey — high device-loss risk
BS=1 ratio> 70% for consumersUsers have non-synced passkeys — higher chance of "lost account" support
p95 latency /assert/verify< 150 msCOSE key parsing slow or Metadata Service blocking

Integrate OpenTelemetry across the whole request chain — include AAGUID and transport in span attributes and you'll immediately see that "authenticator model X has a 40% failure rate" to isolate a firmware issue. Fido2NetLib 5.x already exposes standard ActivitySources — just enable AddSource("Fido2NetLib").

14. Four common anti-patterns

  1. Forcing "direct" attestation on every account: for consumer B2C, direct attestation is annoying (extra permission popups, and some cloud passkeys don't return a cert). Require direct only for compliance-sensitive tiers. Default to "none" or "indirect".
  2. Using the username as user.id: the spec requires user.id to be opaque bytes unrelated to identifying information (UUID or hash). If you use an email as id, changing the email breaks every passkey registered.
  3. Skipping excludeCredentials on register: a user who already has a passkey can create another duplicate, producing "ghost" rows in the schema and confusing UX. Always pass in the current credential list.
  4. Keeping password as recovery: setting passkey as primary but leaving password as a back door without step-up — attackers will just attack the back door. Either force step-up for password, or retire it entirely.

15. Go-live checklist

Before enabling for 100% of users

  • The origins allowlist contains only the production domain + required subdomains, no localhost.
  • RP ID is explicitly set in configuration; staging and production must not share the same RP ID or test users pollute production credentials.
  • The challenge is 32 bytes from RandomNumberGenerator.Create(), stored with a TTL ≤ 5 minutes and bound to the user's session.
  • Tested end-to-end on: desktop Chrome, macOS Safari, Android Chrome, iOS Safari, Firefox, Edge, and Yubikey 5 NFC over USB + NFC.
  • Conditional UI verified working with autocomplete="username webauthn" on both mobile and desktop.
  • Recovery flow has no SMS OTP; only a magic link on a verified email with a 10-minute expiry.
  • The Grafana metrics dashboard has a "registration vs authentication success rate by AAGUID" panel.
  • Incident playbook documents how to revoke a credential when a user reports a lost device, and how to force-logout all sessions.
  • Legal review completed for the AAGUID storage policy (GDPR — AAGUID can indirectly identify the device model).
  • Load test: 1,000 rps against /assert/verify, p95 < 200 ms, and metadata service isn't throttled.

16. Closing — When to ship now and when to hold

Passkeys are one of the rare technologies in the last 20 years that improve both security and experience at the same time — usually those two are in tension. In 2026 the platforms are mature, the libraries are solid, and users are used to seeing Touch ID everywhere. There aren't many technical reasons left to delay.

Still, timing matters. If your product has many shared kiosks, small-business accounts used by many people, or older users not used to biometrics — a slower rollout is wise: keep passwords longer, invest more in UI guidance. Conversely, for tech-savvy consumer products (fintech, productivity SaaS, developer tools), you can accelerate phases 1-2 and reap the security + conversion benefits right away.

Whichever pace you pick, one principle is immutable: don't treat passkeys as "another channel alongside OTP". They're the replacement — and only when you actually remove phishable channels from the main flow does the security win materialize. A system with passkeys that still allows "email me an OTP" as recovery for every account is, from a security standpoint, exactly as strong as a system that only has email OTP.

17. References