
WebAuthn and Passkeys: how passwordless authentication really works
How the WebAuthn protocol behind passkeys works — public keys, attestation, a practical example — and why they're not just a more convenient alternative to passwords.
“No more passwords” is how passkeys get sold to the general public, and that’s not wrong, but it’s the least interesting part of the story. The interesting part is why removing the password also removes entire categories of attack that no minimum-length or forced-expiration policy has ever really solved: phishing, credential stuffing, stolen password databases that turn out to be useful elsewhere. Understanding the protocol behind a passkey helps explain why.
The problem a password always brings with it
A password is a shared secret: both you and the server know it (or should — in practice the server only knows its hash, but the principle holds). Any shared secret is something that can be copied, intercepted, reused elsewhere if the user reuses it across sites, or simply typed into a phishing site that looks identical to the real one.
WebAuthn (the W3C standard behind passkeys) removes the shared secret: it uses public-key cryptography. The user’s device generates a key pair — public and private — specific to each site. The private key never leaves the device (it often lives in a hardware secure enclave). The server only stores the public key, which is by definition useless to an attacker: knowing someone’s public key doesn’t let you authenticate as them.
Registration: creating a passkey
const options = await fetch("/webauthn/register-options").then((r) => r.json());
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64ToBuffer(options.challenge),
rp: { name: "My App", id: "example.com" },
user: {
id: base64ToBuffer(options.userId),
name: "[email protected]",
displayName: "User Name",
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }], // ES256
authenticatorSelection: { residentKey: "required", userVerification: "required" },
},
});
await fetch("/webauthn/register", {
method: "POST",
body: JSON.stringify({ credential: credentialToJSON(credential) }),
});
The challenge is a random value generated by the server, unique to that request — it stops an attacker from replaying a previously captured response (the same principle as a nonce in a Content Security Policy: a value that can’t be predicted and can’t be reused). The user confirms with the device’s authenticator — Face ID, a fingerprint, a physical security key — and only then does the browser generate the key pair and return the public key to be saved server-side.
🔎 Nel dettaglio
userVerification: 'required' isn’t optional in practice. This parameter controls whether the
authenticator must verify that it’s really the owning user (biometrics, PIN) or just that the
device is physically present. For an account’s primary authentication, 'required' is what you
want: without it, a stolen unlocked device would be enough.
Login: proving possession of the private key, without ever revealing it
const options = await fetch("/webauthn/login-options").then((r) => r.json());
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64ToBuffer(options.challenge),
rpId: "example.com",
userVerification: "required",
},
});
await fetch("/webauthn/login", {
method: "POST",
body: JSON.stringify({ assertion: assertionToJSON(assertion) }),
});
The device signs the challenge with the private key. The server verifies the signature using the public key saved during registration. If the signature is valid, it knows with cryptographic certainty that the request comes from whoever holds that private key — with no secret ever having traveled over the network, and so nothing to steal via phishing: even if a user were tricked into visiting a fake site identical to the real one, the browser would generate a signature bound to the real domain used at registration (rpId), which is useless on the fake site.
🔎 Nel dettaglio
The domain binding is the real anti-phishing protection, not an implementation detail: rpId ties
the credential to a specific domain in a way verified by the browser itself, not by your
application code. A phishing site on a different domain, however visually similar, simply cannot
request a valid signature for the original domain. That’s the substantial difference from a
password, which a user can type in wherever they’re asked, including on a fake site.
Attestation: how much you trust the device generating the key
A detail that intro guides often skip: during registration, the authenticator can provide an attestation — cryptographic proof, signed by the device manufacturer, of what kind of authenticator generated that key (a physical YubiKey, a phone’s secure enclave, a software password manager). For a typical consumer app, attestation is almost never needed: trusting that some authenticator generated the key is enough. For contexts with specific compliance requirements (access to banking or government systems, where certified hardware must be guaranteed) attestation becomes the mechanism to verify it — but it requires maintaining an up-to-date list of trusted manufacturers/models, a non-trivial operational cost.
The real-world case quick guides don’t cover: what happens when the user loses the device
This is where the most underestimated practical problem lives. You can recover a password by email. A private key tied to hardware, by definition, isn’t recoverable — the very strength (uncopyable) that also becomes the weak point for recovery.
The solutions in use today are two, often combined:
- Cloud sync of the passkey (iCloud Keychain, Google Password Manager): the private key is synced, encrypted, across the user’s devices within the same ecosystem. Convenient, but it moves the attack surface onto the security of the cloud account doing the syncing.
- Registering multiple passkeys for the same account from the start (phone + backup physical key), exactly as you’d recommend having more than one recovery method for any critical system.
💡 Consiglio
Don’t drop the fallback too quickly. During the transition period — probably still several years out — it makes sense to offer passkeys as the primary option but not the only method, with a verified fallback (email + a second factor, not email alone) for anyone who loses access to all their registered devices. An authentication system that’s perfect on paper but locks out a legitimate user with no way back is, in practice, worse than a simpler but recoverable one.
Where to start
Implementing WebAuthn by hand, handling challenges, encoding, and signature verification directly, is possible but not advisable for a real project: mature libraries like @simplewebauthn/server (Node.js) correctly handle the critical details — challenge validation, signature verification, attestation format handling — that, gotten wrong by hand, become silent vulnerabilities rather than obvious test failures.