Passwords are the problem, not the user. Over 80% of hacking-related breaches still involve stolen or weak credentials, and that number refuses to go down because the whole model is broken: breaches leak them, phishing steals them, people reuse them. (I wrote up the why, for non-developers, separately. Linked at the bottom.)
Passkeys fix this at the root. There's no shared secret to steal, and they literally can't be phished because the credential is bound to your domain. The part most devs don't realize: adding them is surprisingly little code, especially if you lean on a library instead of wrestling raw WebAuthn ArrayBuffers.
Here's a minimal flow using SimpleWebAuthn.
Registration (creating a passkey)
Server side, you hand the browser a challenge:
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
app.post('/register/options', async (req, res) => {
const user = getUser(req);
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'example.com',
userName: user.email,
});
await saveChallenge(user.id, options.challenge);
res.json(options);
});
app.post('/register/verify', async (req, res) => {
const user = getUser(req);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: await getChallenge(user.id),
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});
if (verification.verified) {
await savePasskey(user.id, verification.registrationInfo);
}
res.json({ verified: verification.verified });
});
Browser side is almost nothing:
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
const options = await fetch('/register/options', { method: 'POST' })
.then((r) => r.json());
const attResp = await startRegistration({ optionsJSON: options });
const { verified } = await fetch('/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
}).then((r) => r.json());
if (verified) console.log('Passkey registered');
}
That startRegistration call is what pops the Face ID / fingerprint / PIN prompt. The browser and OS handle the scary crypto. You never touch a private key.
Login (using the passkey)
Same shape, mirror image:
import { startAuthentication } from '@simplewebauthn/browser';
async function loginWithPasskey() {
const options = await fetch('/login/options', { method: 'POST' })
.then((r) => r.json());
const asseResp = await startAuthentication({ optionsJSON: options });
const { verified } = await fetch('/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asseResp),
}).then((r) => r.json());
if (verified) window.location.href = '/dashboard';
}
On the server, generateAuthenticationOptions and verifyAuthenticationResponse do the matching counterparts.
The three things that trip people up
-
rpID must match your domain. It's the registrable domain (
example.com), not a URL, nohttps://, no path. On localhost, uselocalhost. - expectedOrigin must be exact, including the scheme and port. A mismatch here is the number one "it works locally but not in prod" bug.
- The challenge is single-use. Generate it server-side, store it, verify against it once, throw it away. Don't let the client send its own.
That's genuinely most of it. You can ship passkeys as an option alongside passwords first, let users opt in, and watch your account-takeover numbers drop. No rip-and-replace required.
If you want the non-technical version to send to your less-nerdy coworkers (or your boss who keeps asking "are passwords really that bad"), I wrote that up here: The Password You've Been Using Just Got You Hacked
Anyone shipped passkeys to real users yet? Curious how the opt-in rates have looked.
Top comments (0)