Passwordless Auth in Production: What I Learned Implementing WebAuthn
WebAuthn (the standard behind passkeys and hardware-key login) reads like a cryptography paper on first pass, which scares people away from it. The actual crypto is handled entirely by the browser and authenticator — what trips people up in production is everything around it.
The part that's actually simple
You never touch a private key, ever. The browser generates a key pair on the user's device during registration, keeps the private key in secure hardware, and only ever sends the public key to your server. Login is the server sending a challenge, the device signing it with the private key, and the server verifying the signature against the stored public key. No password, no shared secret to leak.
The gotchas nobody's intro post mentions
rpID has to match exactly, including subdomains. If your app is served from app.example.com but you set the relying party ID to example.com without meaning to, credentials registered on one won't validate on the other, and the error message you get back is unhelpfully generic.
You need a fallback, always. Not every device has a platform authenticator, and users lose devices. Shipping WebAuthn as the only login method locks people out in ways password reset flows don't. I ended up treating it as the preferred method with email-based fallback, not a replacement.
Testing locally is harder than it should be. localhost is treated as a secure origin so it mostly works, but simulating "user lost their device" or testing across real hardware keys means you can't fully validate the flow without physical devices or a browser's virtual authenticator devtools panel.
Counter values matter for replay protection. Each authenticator returns an incrementing signature counter; if a signature comes back with a counter lower than what you last saw, that's a signal of a cloned authenticator, not just a bug. Skipping that check quietly removes a real security property.
Takeaway
WebAuthn's security model is genuinely stronger than passwords, but "the crypto is handled for you" doesn't mean "there's nothing to get wrong." Almost every real bug I hit was in relying-party configuration and account-recovery edge cases, not the authentication flow itself.