Skip to main content
DI
authenticationpasskeyspasswordless

Passkeys vs Passwords: The Complete Migration Guide for Enterprise

A hands-on guide to migrating from passwords to passkeys in the enterprise, covering FIDO2/WebAuthn fundamentals, phased rollout strategies, implementation code examples, and how to handle the edge cases that trip up most organizations.

Deepak GuptaMarch 5, 202615 min read
Share:

Why Passwords Need to Die

This isn't hyperbole. Passwords are the single largest attack surface in enterprise security. According to recent breach data, over 80% of web application breaches involve stolen or weak credentials. Phishing, credential stuffing, brute force attacks, password spray -- these are not sophisticated techniques. They work because passwords are fundamentally flawed as an authentication mechanism.

Passkeys fix this. Built on the FIDO2 and WebAuthn standards, passkeys replace passwords with public-key cryptography. There is no shared secret to steal. There is nothing to phish. The private key never leaves the user's device.

This guide covers how passkeys work, how they compare to passwords across every dimension that matters, and how to plan and execute a migration in your enterprise.

Password vs. Passkey Authentication Flow {/* LEFT: Password flow */} Password Flow (Vulnerable) {/* User */} User {/* Arrow: types password */} types password {/* Browser */} Browser {/* Arrow: sends over TLS */} sends password {/* Server */} Server stores hash {/* Vulnerabilities */} Attack Vectors: 1. Phishing: User tricked into entering password on fake site 2. Credential stuffing: Reused passwords from other breaches 3. Server breach: Password hashes stolen and cracked 4. MitM: Password intercepted in transit (despite TLS) {/* Divider */} {/* RIGHT: Passkey flow */} Passkey Flow (Phishing-Resistant) {/* User */} User biometric {/* Arrow */} unlocks {/* Authenticator */} Authenticator private key {/* Arrow: signs challenge */} signed challenge {/* Browser */} Browser origin check {/* Arrow: sends assertion */} assertion {/* Server */} Server public key only {/* Protections */} Why attacks fail: 1. Phishing: Browser verifies origin -- won't sign challenge for wrong domain 2. Credential stuffing: No password to reuse. Each site gets a unique key pair 3. Server breach: Only public keys stored. Useless to attackers 4. MitM: Challenge-response is bound to TLS channel. Replay is impossible {/* Bottom legend */} = Shared secret (vulnerable) = Public-key cryptography (phishing-resistant)

How Passkeys Work Under the Hood

Understanding the cryptographic foundation is essential for anyone planning a deployment. Passkeys are built on two W3C/FIDO standards:

  • WebAuthn: The browser API that web applications use to request credential creation and authentication.
  • CTAP2 (Client to Authenticator Protocol): The protocol between the browser and the authenticator device (security key, phone, platform biometric).

The Registration Ceremony

When a user registers a passkey, the following happens:

WebAuthn Registration Ceremony {/* Actors */} User Authenticator Relying Party (RP) {/* Vertical lines */} {/* Step 1 */} 1. User clicks "Register Passkey" {/* Step 2 */} 2. RP sends challenge + user info + RP ID (navigator.credentials.create() called) {/* Step 3 */} 3. User verifies (biometric/PIN) {/* Step 4 */} 4. Authenticator: - Generates key pair (pub + priv) - Stores private key securely - Signs challenge with private key {/* Step 5 */} 5. Send public key + signed challenge + attestation {/* Step 6 */} 6. RP verifies and stores: - Public key - Credential ID - User handle {/* Step 7 */} 7. Registration complete -- passkey ready to use

The critical security property: the private key never leaves the authenticator. The server only ever sees the public key. There is literally nothing to steal from the server that would allow an attacker to impersonate the user.

Implementation: Server-Side Code

Here's a practical implementation using the @simplewebauthn/server library (one of the most popular WebAuthn server libraries for Node.js):

Registration Endpoint

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpName = 'My Enterprise App';
const rpID = 'app.example.com';
const origin = https://${rpID};

// Step 1: Generate registration options
app.post('/api/passkey/register/options', async (req, res) => {
const user = await getAuthenticatedUser(req);
const existingCredentials = await getUserCredentials(user.id);

const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
userDisplayName: user.displayName,
// Prevent re-registering existing authenticators
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});

// Store challenge for verification
await storeChallenge(user.id, options.challenge);

res.json(options);
});

// Step 2: Verify registration response
app.post('/api/passkey/register/verify', async (req, res) => {
const user = await getAuthenticatedUser(req);
const expectedChallenge = await getStoredChallenge(user.id);

const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});

if (verification.verified && verification.registrationInfo) {
const { credentialPublicKey, credentialID, counter } =
verification.registrationInfo;

// Store the credential
await storeCredential({
userId: user.id,
credentialID,
credentialPublicKey,
counter,
transports: req.body.response.transports,
});

res.json({ verified: true });
} else {
res.status(400).json({ verified: false });
}
});

Authentication Endpoint

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

// Step 1: Generate authentication options
app.post('/api/passkey/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
// If you want discoverable credentials (usernameless login),
// leave allowCredentials empty
allowCredentials: [],
userVerification: 'preferred',
});

await storeChallenge(req.sessionID, options.challenge);
res.json(options);
});

// Step 2: Verify authentication response
app.post('/api/passkey/login/verify', async (req, res) => {
const expectedChallenge = await getStoredChallenge(req.sessionID);
const credential = await getCredentialById(req.body.id);

if (!credential) {
return res.status(400).json({ error: 'Unknown credential' });
}

const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialPublicKey: credential.credentialPublicKey,
credentialID: credential.credentialID,
counter: credential.counter,
},
});

if (verification.verified) {
// Update the counter to prevent replay attacks
await updateCredentialCounter(
credential.credentialID,
verification.authenticationInfo.newCounter
);

// Create session
const user = await getUserById(credential.userId);
req.session.userId = user.id;
req.session.authMethod = 'passkey';

res.json({ verified: true });
}
});

Client-Side Code

import {
  startRegistration,
  startAuthentication,
} from '@simplewebauthn/browser';

// Registration
async function registerPasskey() {
// Get options from server
const optionsRes = await fetch('/api/passkey/register/options', {
method: 'POST',
});
const options = await optionsRes.json();

// Trigger the browser's WebAuthn UI
const registration = await startRegistration(options);

// Send to server for verification
const verifyRes = await fetch('/api/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registration),
});

const result = await verifyRes.json();
if (result.verified) {
console.log('Passkey registered successfully!');
}
}

// Authentication
async function loginWithPasskey() {
const optionsRes = await fetch('/api/passkey/login/options', {
method: 'POST',
});
const options = await optionsRes.json();

const authentication = await startAuthentication(options);

const verifyRes = await fetch('/api/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authentication),
});

const result = await verifyRes.json();
if (result.verified) {
window.location.href = '/dashboard';
}
}

Enterprise Migration Strategy

Deploying passkeys in an enterprise is not just a technical challenge -- it's a change management challenge. Here's a phased approach that works.

Phase 1: Coexistence (Months 1-3)

  • Enable passkey registration alongside existing authentication. Don't force anyone to switch yet.
  • Target IT and security staff first. They understand the technology and can be your champions.
  • Monitor registration rates and support tickets. This data will inform your rollout pace.
  • Ensure your Identity Provider supports passkeys. Most major IdPs (Okta, Microsoft Entra ID, Ping Identity) now offer native passkey support.

Phase 2: Encouragement (Months 3-6)

  • Prompt users to register passkeys at login. A non-dismissable but skippable prompt after password authentication.
  • Highlight the benefits in internal communications. Faster login, no more forgotten passwords, no more MFA codes.
  • Set targets: Aim for 60-70% passkey registration among active users by end of this phase.
  • Begin disabling weaker MFA methods (SMS OTP) for users who have registered passkeys.

Phase 3: Default (Months 6-9)

  • Make passkeys the default authentication method. Passwords remain available as a fallback.
  • Implement risk-based authentication: If a user has a passkey but falls back to password, flag it as a higher-risk signal.
  • Disable password-only authentication for roles with access to sensitive data.

Phase 4: Password Elimination (Months 9-12)

  • Remove password fields from login UI for users with passkeys. They can still request a recovery flow if needed.
  • Implement account recovery flows that don't rely on passwords: recovery keys, admin-assisted recovery, identity proofing.
  • For compliance: Document how your passkey deployment meets NIST 800-63 AAL2 or AAL3 requirements.

Handling Edge Cases

Lost Device Recovery

The number one concern from enterprise buyers. Solutions:

  • Multi-device passkeys (synced). Passkeys synced via iCloud Keychain or Google Password Manager survive a single device loss.
  • Multiple registered authenticators. Require users to register at least two passkeys (e.g., phone + laptop, or phone + hardware key).
  • Admin-assisted recovery. IT helpdesk can verify identity through an out-of-band channel and issue a temporary registration link.
  • Recovery codes. Single-use recovery codes stored securely by the user. Old school but effective.

Shared Workstations

Kiosk and shared workstation environments require roaming authenticators (phones or hardware security keys) rather than platform authenticators. Use conditional UI to detect the environment and guide users to the appropriate flow.

Legacy Application Support

Not every application supports WebAuthn. For legacy apps:

  • Use a reverse proxy or identity-aware proxy that handles WebAuthn authentication and passes identity tokens downstream
  • Bridge through your SSO infrastructure -- authenticate with passkeys at the IdP, federate to the legacy app via SAML or OIDC

The User Experience Advantage

Beyond security, passkeys deliver a fundamentally better user experience:

MetricPasswords + MFAPasskeys
Average login time14-20 seconds3-5 seconds
Support tickets (auth-related)20-30% of all ticketsReduced by 60-80%
Login success rate85-90%98%+
Phishing susceptibilityHighZero (origin-bound)
User training requiredModerateMinimal

The combined security and UX improvement is why passkeys are the rare security initiative that users actually prefer. For deeper background on modern authentication architecture, see Solving Identity Management in Modern Applications.

What About Hardware Security Keys?

Hardware security keys (YubiKey, Titan Key, etc.) are still relevant, especially for high-assurance scenarios:

  • Privileged access management (PAM): Require hardware keys for admin access to critical infrastructure
  • Compliance requirements: Some regulations require a physical authenticator
  • Air-gapped environments: Where phones aren't allowed, hardware keys are the only passkey option
The good news is that hardware keys and platform passkeys use the same FIDO2 protocol, so your server-side implementation handles both identically.

Start Today

Every week you delay passkey deployment is another week your organization is vulnerable to phishing and credential theft. The technology is mature, the ecosystem is ready, and the migration path is well-established. The question is no longer "should we adopt passkeys?" -- it's "how fast can we move?"

Enjoyed this article?

Subscribe for more expert insights on digital identity, IAM, and security.