Passwordless Authentication with WebAuthn and Passkeys: A Developer's Guide
Passwords get phished, reused, and leaked in breaches. One-time codes sent by SMS get intercepted by SIM-swap attacks. WebAuthn — the browser standard behind passkeys — solves authentication differently: instead of a secret the user types and can be tricked into revealing, it uses public-key cryptography where the private key never leaves the user's device.
How WebAuthn Actually Works
When a user registers a passkey, their device (a phone, a hardware key, a laptop's secure enclave) generates a public/private key pair specific to that site. The private key stays on the device — it's never transmitted, never stored on your server, and never typed by the user. Your server stores only the public key.
To log in, your server sends a random challenge. The device signs that challenge with the private key, and the browser returns the signature. Your server verifies the signature against the stored public key. No secret ever crosses the network — there's nothing to phish, because there's nothing the user knows or types that a fake login page could capture.
Why this resists phishing specifically
The signature WebAuthn produces is cryptographically bound to the origin (the domain) that requested it. If a user is tricked into visiting yourbank-secure.com instead of yourbank.com, their browser will refuse to produce a valid signature for the wrong origin — the attack that works against passwords and OTP codes simply doesn't have a foothold here.
Passkeys vs Raw WebAuthn
"Passkey" is the user-facing name for a WebAuthn credential that's synced across a user's devices via their platform (iCloud Keychain, Google Password Manager, etc.), rather than being locked to a single physical device. From an implementation standpoint, you're using the same WebAuthn APIs either way — passkeys are a deployment and UX model built on top of the same underlying protocol, not a separate technology.
Implementing Registration
// Server: generate registration options
import { generateRegistrationOptions } from '@simplewebauthn/server'
const options = await generateRegistrationOptions({
rpName: 'Your App',
rpID: 'yourapp.com',
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
})
// Store options.challenge against the user's session for verification
// Client: trigger the browser's native passkey creation flow
import { startRegistration } from '@simplewebauthn/browser'
const attestation = await startRegistration(options)
// Send attestation back to your server to verify and store the public key
Implementing Authentication
// Server: generate authentication options with a fresh challenge
const options = await generateAuthenticationOptions({
rpID: 'yourapp.com',
userVerification: 'preferred',
})
// Client: trigger the browser's native passkey sign-in flow
import { startAuthentication } from '@simplewebauthn/browser'
const assertion = await startAuthentication(options)
// Send assertion back to your server to verify against the stored public key
The browser handles the biometric or PIN prompt, key generation, and signing — none of that logic lives in your application code.
What Implementation Actually Involves
- A fallback path. Not every user has a passkey-capable device or browser yet. You need a secondary flow (existing password, or email-based recovery) for users who can't complete WebAuthn.
- Multiple credentials per user. Users register passkeys on multiple devices; your data model needs to store a list of public keys per account, not a single one.
- Signature counter handling. WebAuthn includes a counter to detect cloned authenticators; your server needs to check it's monotonically increasing on each authentication.
- Recovery flow design. If a user loses every device with a registered passkey, you need an account recovery process that doesn't reintroduce the phishing risk you just eliminated.
Conclusion
WebAuthn removes the shared-secret problem that makes passwords and OTPs phishable in the first place — the credential is bound to both the device and the origin, so there's nothing for an attacker to trick a user into revealing. The protocol itself is a handful of well-documented API calls; the real implementation work is in credential management, fallback flows, and recovery design around it.