Start with Identity
Recipe · Advanced · 45 min

Add passkeys with WebAuthn

Implement passkey registration and login using the WebAuthn API and a server-side verification library, the phishing-resistant replacement for passwords.

WebAuthnPasskeysSecurity
Before you start
  • A backend you can add two endpoints to
  • HTTPS (or localhost) and a defined relying party ID
  • A WebAuthn server library such as @simplewebauthn/server

Passkeys are WebAuthn credentials: a public/private key pair where the private key never leaves the user's device or password manager. Authentication is a signed challenge, so there is no shared secret to phish or leak. The FIDO Alliance reports passkeys block the credential phishing that drives most account takeover. This recipe implements both ceremonies using @simplewebauthn on the server and the browser's navigator.credentials API on the client. For the standard, see WebAuthn / FIDO2.

There are two ceremonies: registration (create and store a passkey) and authentication (prove possession of a stored passkey). Each is a two-step server round trip: the server issues options with a challenge, the browser performs the ceremony, the server verifies the result.

Relying party basics

The relying party (RP) is your site. Pin these once:

const rpName = 'Example';
const rpID = 'example.com';           // registrable domain, no scheme or port
const origin = 'https://example.com'; // exact origin the browser will report

The browser binds each credential to the RP ID, which is what makes passkeys phishing-resistant: a credential for example.com will not be offered on examp1e.com.

1. Registration: server issues options

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

app.post('/webauthn/register/start', auth, async (req, res) => {
  const user = req.user; // the already-signed-in user adding a passkey
  const options = await generateRegistrationOptions({
    rpName, rpID,
    userName: user.email,
    userID: new TextEncoder().encode(user.id),
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
  });
  await db.saveChallenge(user.id, options.challenge); // store to verify later
  res.json(options);
});

2. Registration: browser creates the credential

import { startRegistration } from '@simplewebauthn/browser';

const options = await fetch('/webauthn/register/start', { method: 'POST' })
  .then((r) => r.json());
const attResp = await startRegistration({ optionsJSON: options });
await fetch('/webauthn/register/finish', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(attResp),
});

3. Registration: server verifies and stores

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

app.post('/webauthn/register/finish', auth, async (req, res) => {
  const expectedChallenge = await db.getChallenge(req.user.id);
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: 'verification_failed' });
  }
  const { credential } = verification.registrationInfo;
  await db.saveCredential(req.user.id, {
    credentialID: credential.id,
    publicKey: credential.publicKey, // store the public key, never a secret
    counter: credential.counter,
  });
  res.json({ verified: true });
});

You store only the public key and a signature counter. There is no secret on your server to steal, which is the core security improvement over passwords.

4. Authentication: server issues a challenge

import { generateAuthenticationOptions } from '@simplewebauthn/server';

app.post('/webauthn/login/start', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    // allowCredentials can be empty for usernameless/discoverable passkeys
  });
  await db.saveLoginChallenge(req.sessionID, options.challenge);
  res.json(options);
});

5. Authentication: browser signs, server verifies

import { startAuthentication } from '@simplewebauthn/browser';
const options = await fetch('/webauthn/login/start', { method: 'POST' }).then((r) => r.json());
const asseResp = await startAuthentication({ optionsJSON: options });
await fetch('/webauthn/login/finish', {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify(asseResp),
});
import { verifyAuthenticationResponse } from '@simplewebauthn/server';

app.post('/webauthn/login/finish', async (req, res) => {
  const cred = await db.getCredentialById(req.body.id);
  if (!cred) return res.status(404).json({ error: 'unknown_credential' });
  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: await db.getLoginChallenge(req.sessionID),
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: cred.credentialID,
      publicKey: cred.publicKey,
      counter: cred.counter,
    },
  });
  if (!verification.verified) return res.status(401).json({ error: 'auth_failed' });
  await db.updateCounter(cred.credentialID, verification.authenticationInfo.newCounter);
  // Establish your session here.
  res.json({ verified: true });
});

What to get right

  • Verify the origin and RP ID on the server. This is what stops a phished credential from a look-alike domain. Never trust client-reported values.
  • Persist and check the signature counter to detect cloned authenticators, where supported.
  • Offer discoverable (resident) credentials so users can log in without typing a username, the smoothest passkey experience.
  • Keep a fallback. Let users register more than one passkey, and keep a recovery path for a lost device.

Passkeys are the practical form of phishing-resistant authentication discussed in the authentication guide. The protocol is specified in WebAuthn / FIDO2.

Free to read and use. Code is illustrative, vendor-neutral, and built on the open standards. Review it against your provider's docs and your threat model before shipping. See more in the recipes index and the standards library.