Passkeys Explained: Passwordless Login with WebAuthn

A
Author
25 Sep, 2026 2 min read 233 views

A passkey replaces a password with a key pair. The private key stays on the user's device (or syncs through their password manager), and your server stores only the public key. Signing in means proving possession of the private key, usually with a fingerprint, face or device PIN.

Why passkeys are better

  • Phishing-resistant: a passkey is bound to your domain, so a look-alike site can't use it.
  • Nothing to leak: a database breach exposes only public keys.
  • No reuse, no weak passwords, and faster sign-in for users.

Registration flow

// server
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';

const rpID = 'example.com';
const origin = 'https://example.com';

app.post('/passkeys/register/options', requireLogin, async (req, res) => {
  const options = await generateRegistrationOptions({
    rpName: 'Example App',
    rpID,
    userName: req.user.email,
    excludeCredentials: (await db.passkeys.forUser(req.user.id)).map((p) => ({ id: p.credentialId })),
  });
  req.session.challenge = options.challenge;
  res.json(options);
});

app.post('/passkeys/register/verify', requireLogin, async (req, res) => {
  const { verified, registrationInfo } = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: req.session.challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
  if (!verified) return res.status(400).json({ error: 'Could not verify passkey' });
  const { credential } = registrationInfo;
  await db.passkeys.create({
    userId: req.user.id,
    credentialId: credential.id,
    publicKey: Buffer.from(credential.publicKey),
    counter: credential.counter,
  });
  res.json({ ok: true });
});
// browser
import { startRegistration } from '@simplewebauthn/browser';

const optionsJSON = await (await fetch('/passkeys/register/options', { method: 'POST' })).json();
const attestation = await startRegistration({ optionsJSON });   // shows the OS passkey prompt
await fetch('/passkeys/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(attestation),
});

Sign-in flow

Sign-in mirrors registration: the server creates a challenge with generateAuthenticationOptions, the browser calls startAuthentication, and the server checks the signature with verifyAuthenticationResponse against the stored public key, then starts a session.

Rolling out passkeys

  • Offer "Create a passkey" after a normal sign-in; keep existing methods as a fallback at first.
  • Enable autofill sign-in (conditional UI) so passkeys appear in the username field's suggestions.
  • Let users see and remove their passkeys in account settings.
  • Make account recovery strong; it becomes the weakest link once passwords are gone.
Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.