Passkeys and WebAuthn: The End of Passwords
Passwords are the weakest link in web security. Users reuse them across sites, phishing attacks steal them daily, and even strong passwords end up in data breaches. Passkeys — built on the WebAuthn/FIDO2 standard — replace passwords with cryptographic key pairs stored on user devices. They’re phishing-resistant by design, faster than typing passwords, and supported by Apple, Google, and Microsoft across all major platforms. Therefore, this guide covers how passkeys work, how to implement them, and how to migrate your existing authentication system.
How Passkeys Work: The Cryptography Behind the UX
A passkey is a public-private key pair. The private key never leaves the user’s device — it’s stored in the platform authenticator (Touch ID, Face ID, Windows Hello) or a hardware security key. The server only stores the public key. During authentication, the server sends a challenge, the device signs it with the private key, and the server verifies the signature with the stored public key.
This architecture makes passkeys phishing-resistant because the private key is bound to the origin (domain). If a user visits a phishing site at evil-bank.com, the authenticator won’t find a passkey for that domain — the attack simply doesn’t work. Moreover, there’s no shared secret (password) that can be stolen from the server. Even if your database is compromised, attackers get only public keys — which are useless without the corresponding private keys locked in users’ devices.
Cross-device sync is handled by platform providers: Apple syncs passkeys via iCloud Keychain, Google via Google Password Manager, and Microsoft via Microsoft Account. A passkey created on your iPhone automatically appears on your Mac, iPad, and any browser signed into your Apple account. Additionally, cross-device authentication lets you scan a QR code on your phone to sign in on a computer that doesn’t have your passkey — the phone acts as the authenticator via Bluetooth proximity.
Implementing WebAuthn Registration
WebAuthn registration (creating a passkey) involves three steps: the server generates a challenge, the browser calls navigator.credentials.create() which triggers the platform authenticator, and the server verifies and stores the credential.
// SERVER: Generate registration options
// Using @simplewebauthn/server (Node.js)
import {
generateRegistrationOptions,
verifyRegistrationResponse
} from '@simplewebauthn/server';
const rpName = 'My Application';
const rpID = 'myapp.com';
const origin = 'https://myapp.com';
// Step 1: Generate challenge
app.post('/api/auth/register/options', async (req, res) => {
const user = await getUser(req.session.userId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
userDisplayName: user.name,
attestationType: 'none', // Don't need device attestation
authenticatorSelection: {
residentKey: 'preferred', // Discoverable credential
userVerification: 'preferred', // Biometric if available
authenticatorAttachment: 'platform' // Built-in authenticator
},
excludeCredentials: user.passkeys.map(pk => ({
id: pk.credentialID,
type: 'public-key'
}))
});
// Store challenge for verification
req.session.currentChallenge = options.challenge;
res.json(options);
});
// Step 3: Verify and store credential
app.post('/api/auth/register/verify', async (req, res) => {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID
});
if (verification.verified) {
// Store the public key credential
await savePasskey(req.session.userId, {
credentialID: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
createdAt: new Date()
});
res.json({ success: true });
}
});// CLIENT: Browser-side registration
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
// Get options from server
const optionsRes = await fetch('/api/auth/register/options',
{ method: 'POST' });
const options = await optionsRes.json();
// Trigger platform authenticator (Touch ID, Face ID, etc.)
const credential = await startRegistration(options);
// Send credential to server for verification
const verifyRes = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential)
});
const result = await verifyRes.json();
if (result.success) {
console.log('Passkey registered successfully!');
}
}Implementing WebAuthn Authentication
Authentication follows a similar flow: the server generates a challenge, the browser triggers the authenticator, and the server verifies the signed challenge against the stored public key.
// SERVER: Authentication flow
import {
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
// Step 1: Generate authentication challenge
app.post('/api/auth/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
// Empty allowCredentials = discoverable credential
// The browser will show all passkeys for this domain
allowCredentials: [],
userVerification: 'preferred'
});
req.session.currentChallenge = options.challenge;
res.json(options);
});
// Step 3: Verify authentication
app.post('/api/auth/login/verify', async (req, res) => {
const passkey = await findPasskeyByCredentialID(
req.body.id
);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: passkey.credentialID,
credentialPublicKey: passkey.publicKey,
counter: passkey.counter
}
});
if (verification.verified) {
// Update counter (replay protection)
await updatePasskeyCounter(
passkey.id,
verification.authenticationInfo.newCounter
);
// Create session
req.session.userId = passkey.userId;
res.json({ success: true });
}
});The user experience is dramatically better than passwords. Instead of typing an email and password, the user clicks “Sign in” and uses Face ID, Touch ID, or Windows Hello. The entire flow takes 2-3 seconds. Furthermore, there’s no “forgot password” flow, no password reset emails, and no credential stuffing attacks to worry about.
Migration Strategy: Passwords to Passkeys
You can’t switch to passkey-only authentication overnight — not all users have compatible devices, and some environments (shared computers, older devices) don’t support passkeys. The practical migration path is progressive.
Phase 1: Offer passkeys alongside passwords. After a user logs in with their password, prompt them to create a passkey. Make it a simple, optional flow: “Sign in faster with Face ID?” Users who create passkeys get a better experience immediately.
Phase 2: Make passkeys the default. New account registration defaults to passkey creation. The login page shows a “Sign in with passkey” button prominently, with “Sign in with password” as a secondary option. Track adoption metrics — when 80%+ of logins use passkeys, move to phase 3.
Phase 3: Password-optional accounts. Allow users to remove their password entirely, going passkey-only. This eliminates the password as an attack vector for those users. However, always maintain a recovery mechanism (backup codes, email-based recovery) for users who lose all their devices.
Security Considerations and Edge Cases
Passkeys solve phishing and credential stuffing but introduce new considerations. Device loss is the primary concern: if a user loses all devices and hasn’t synced passkeys to the cloud, they lose access. Implement account recovery through verified email, SMS backup codes, or identity verification. Additionally, passkey sync through iCloud Keychain or Google Password Manager means that compromising someone’s Apple/Google account could compromise their passkeys — though this is still far more secure than password reuse across dozens of sites.
For enterprise deployments, consider attestation — verifying that passkeys were created on approved devices (company-issued hardware keys). The WebAuthn spec supports attestation, though most consumer applications should set attestation to “none” for maximum compatibility.
Related Reading:
Resources:
In conclusion, passkeys and WebAuthn represent the most significant authentication improvement in decades. They eliminate phishing, credential stuffing, and password reuse — the three biggest authentication threats. Start by offering passkeys alongside passwords today, track adoption, and progressively make passwords optional. The user experience is better, the security is stronger, and the ecosystem support from Apple, Google, and Microsoft guarantees long-term viability.