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.
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:
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:
| Metric | Passwords + MFA | Passkeys |
|---|---|---|
| Average login time | 14-20 seconds | 3-5 seconds |
| Support tickets (auth-related) | 20-30% of all tickets | Reduced by 60-80% |
| Login success rate | 85-90% | 98%+ |
| Phishing susceptibility | High | Zero (origin-bound) |
| User training required | Moderate | Minimal |
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
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?"