Passkeys have been "almost ready for mainstream" for three years. In 2026 they actually are. Safari, Chrome, and Firefox all have solid WebAuthn support. iOS and Android handle cross-device passkeys via iCloud Keychain and Google Password Manager. The hardware ecosystem โ YubiKeys, Windows Hello, platform authenticators โ is mature. The remaining friction is on the server side: understanding what you're actually storing, how verification works, and how to handle the fallback cases that still come up. This post covers the implementation from both ends.
What you're actually implementing
WebAuthn is a W3C specification. Passkeys are a FIDO Alliance branding term for discoverable credentials โ WebAuthn credentials stored on a device that can be used without first entering a username. Under the hood, a passkey is a public key credential: the private key lives on the authenticator (device, hardware token, or cloud-synced keychain), the public key is stored on your server, and authentication is a challenge-response signed by the private key. Your server never sees the private key.
Registration flow
// Server: generate a registration challenge
// Using @simplewebauthn/server (Node.js โ supports most platforms)
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
async function startRegistration(userId: string, userEmail: string) {
const options = await generateRegistrationOptions({
rpName: '47Network', // Relying Party name (shown to user)
rpID: 'the47network.com', // Must match your domain exactly
userID: userId,
userName: userEmail,
timeout: 60000,
attestationType: 'none', // 'none' for passkeys; 'direct' for hardware tokens
authenticatorSelection: {
residentKey: 'required', // Required for discoverable credentials (passkeys)
userVerification: 'required', // Require PIN/biometric on the authenticator
authenticatorAttachment: 'platform', // 'platform' = device only; omit to allow hardware keys
},
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
});
// Store challenge in session โ must be verified within the timeout
await session.set(`webauthn:challenge:${userId}`, options.challenge, { ttl: 60 });
return options; // Send to browser
}
// Browser: create the credential
const attResp = await startAuthentication(options); // @simplewebauthn/browser
// Server: verify the registration response
async function completeRegistration(userId: string, attResp: RegistrationResponseJSON) {
const expectedChallenge = await session.get(`webauthn:challenge:${userId}`);
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge,
expectedOrigin: 'https://the47network.com',
expectedRPID: 'the47network.com',
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed');
}
// Store the credential โ this is what you keep server-side
await db.credentials.create({
userId,
credentialID: verification.registrationInfo.credentialID, // Bytes
credentialPublicKey: verification.registrationInfo.credentialPublicKey, // Bytes (COSE format)
counter: verification.registrationInfo.counter, // Signature counter
credentialDeviceType: verification.registrationInfo.credentialDeviceType, // 'singleDevice' or 'multiDevice'
credentialBackedUp: verification.registrationInfo.credentialBackedUp, // Synced to cloud keychain?
transports: attResp.response.transports, // For authentication hints
});
}
What to store: credentialID, credentialPublicKey, and counter are the minimum. The counter is used for clone detection โ if you see a counter value lower than the stored one, someone may have cloned the authenticator. In practice, cloud-synced passkeys may not increment the counter reliably; treat it as a hint rather than a hard security control.
Authentication flow
// Server: generate authentication options
async function startAuthentication(userId?: string) {
const userCredentials = userId
? await db.credentials.findByUserId(userId)
: []; // Empty array triggers discoverable credential (passkey) flow โ no username needed
const options = await generateAuthenticationOptions({
rpID: 'the47network.com',
timeout: 60000,
allowCredentials: userCredentials.map(cred => ({
id: cred.credentialID,
transports: cred.transports,
})),
userVerification: 'required',
});
await session.set('webauthn:auth:challenge', options.challenge, { ttl: 60 });
return options;
}
// Server: verify the authentication response
async function completeAuthentication(authResp: AuthenticationResponseJSON) {
const expectedChallenge = await session.get('webauthn:auth:challenge');
// Find the credential by ID
const credential = await db.credentials.findByCredentialID(authResp.id);
if (!credential) throw new Error('Credential not found');
const verification = await verifyAuthenticationResponse({
response: authResp,
expectedChallenge,
expectedOrigin: 'https://the47network.com',
expectedRPID: 'the47network.com',
authenticator: {
credentialID: credential.credentialID,
credentialPublicKey: credential.credentialPublicKey,
counter: credential.counter,
transports: credential.transports,
},
requireUserVerification: true,
});
if (!verification.verified) throw new Error('Authentication failed');
// Update the counter
await db.credentials.updateCounter(credential.id, verification.authenticationInfo.newCounter);
return credential.userId; // Successful โ return the authenticated user ID
}
Cross-device passkeys: the sync story
A multi-device credential (credentialDeviceType: 'multiDevice') is synced across devices via a platform keychain โ Apple iCloud Keychain, Google Password Manager, or a third-party manager like PassVault. The private key material leaves the device (encrypted, under the platform's key hierarchy) to sync across the user's devices. This is what most people mean when they say "passkey."
A single-device credential is bound to one authenticator โ a hardware security key or a device that doesn't sync. It's lost if the device is lost, but it's also phishing-proof in ways that synced passkeys aren't (if the sync provider is compromised, synced passkeys could theoretically be extracted).
For consumer applications, multi-device credentials are the right default. For high-assurance applications (admin access, financial operations), require single-device credentials (hardware keys like YubiKey) and treat the credentialDeviceType field as an access control signal.
Fallback strategy: passkeys alongside passwords
For most applications in 2026, passkeys are an addition to passwords, not a replacement. Here's the approach that works:
- New accounts: offer passkey registration immediately after password-based signup. Don't require it, but make it prominent and explain the benefit.
- Existing accounts: prompt for passkey registration on next login. A single prompt with a clear one-click path has 3โ5ร better adoption than burying it in account settings.
- Passwordless login: on the login form, show "Sign in with passkey" as the primary option if the browser/device has a registered credential. Fall through to email/password for unrecognised devices.
- Account recovery: never tie account recovery to passkey โ provide a separate recovery path (email code, recovery phrase) so losing all authenticators isn't catastrophic.
PassVault supports WebAuthn: PassVault accounts can be secured with a device passkey in addition to the master password. The passkey is registered as a second factor โ it never replaces the Argon2id master password that derives the vault encryption key, since the server-side authentication and the client-side key derivation are separate operations.