Passkeys in production: a practical WebAuthn migration guide
Passwords are finally on their way out. What we learned rolling out passkeys across a multi-product platform: enrollment flows, fallback strategy, and the edge cases nobody warns you about.
Passkey support crossed the usability threshold sometime in 2025: every major browser, both mobile platforms, and every password manager now handle them natively. In 2026 the question isn't whether to support passkeys โ it's how to migrate an existing password-based user base without locking anyone out. Here's the playbook we used across the Appesto platform.
The mental model: a keypair per site, synced by the platform
A passkey is a WebAuthn credential: a public/private keypair generated by the user's device, scoped to your domain. The private key never leaves the authenticator (or its encrypted sync fabric โ iCloud Keychain, Google Password Manager, 1Password). Your server stores only the public key. There is nothing to phish, nothing to reuse across sites, and nothing to leak in a database breach. Domain scoping is also why passkeys kill phishing dead: a credential registered for appesto.com simply will not respond on appesto-login.evil.com.
Migration order matters
- Phase 1: offer passkey enrollment to signed-in users (post-login prompt, settings page). Password stays as fallback. This builds coverage with zero lockout risk.
- Phase 2: make passkeys the default sign-in UX - show the passkey prompt first, with 'use password instead' one click away.
- Phase 3 (months later): allow passkey-only accounts for new signups, and let existing users delete their password once they have two registered authenticators.
- Never force-delete passwords server-side. Users with one registered device and no sync will get locked out the day they drop their phone in a lake.
The server side is smaller than you think
// Registration verification with @simplewebauthn/server
import { verifyRegistrationResponse } from '@simplewebauthn/server';
const verification = await verifyRegistrationResponse({
response: body.credential,
expectedChallenge: session.challenge,
expectedOrigin: 'https://appesto.com',
expectedRPID: 'appesto.com',
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
await db.insert(authenticators).values({
userId: session.userId,
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: body.credential.response.transports,
});
}Two server-side rules that save you pain later: store the transports array so the browser can route the next sign-in to the right authenticator without guessing, and treat the signature counter as advisory โ synced passkeys legitimately report zero forever, so a strict counter check will reject real users.
Edge cases from the real rollout
- Corporate Windows machines with Windows Hello disabled by group policy silently fail enrollment - detect with PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() and hide the prompt.
- Users share accounts more than you think. Passkeys are per-person by design, which surfaces every shared login in your user base as a support ticket.
- Cross-device sign-in (scanning a QR code with your phone) works remarkably well but needs Bluetooth proximity - it fails inside VMs and remote desktops, so keep a fallback path.
- Account recovery becomes your weakest link. If recovery is 'email a magic link', your account security equals your email security - be honest about that in your threat model.
What we'd tell a team starting today
Use a maintained library (@simplewebauthn or your auth provider's built-in support) rather than hand-rolling CBOR parsing. Ship phase 1 in a week, watch enrollment rates, and let the data pace the rollout. Our numbers: 40% of active users enrolled a passkey within three months when prompted post-login, and passkey sign-ins have a measurably higher success rate than password entry on mobile โ fewer typos, no reset flow, faster by seconds.
Written by Appesto Engineering.