# Add passkeys with WebAuthn

Source: https://startwithidentity.com/recipes/add-passkeys-webauthn/
Last updated: 2026-06-19
License: content by Start with Identity. Cite the source URL.

---

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](https://startwithidentity.com/standards/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:

```ts
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

```ts
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

```ts
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

```ts
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

```ts
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

```ts
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),
});
```

```ts
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](https://startwithidentity.com/guides/authentication/). The protocol is specified in [WebAuthn / FIDO2](https://startwithidentity.com/standards/webauthn-fido2/).
