Registration

startRegistration and finishRegistration, and the fifteen checks in between.

Creating a passkey. Two calls: one to issue options, one to verify what comes back.

startRegistration

startRegistrationmethod

startRegistration(input: StartRegistrationInput): Promise<StartRegistrationResult>

Issues creation options for the browser. Send result.options across untouched; the client library converts it to the WebAuthn shape at the last moment.

usernamestring
displayNamestringdefault username
userIdstring
userVerification'required' | 'preferred' | 'discouraged'default config value

Returns

{ options: RegistrationOptionsJSON; userId: string; isNewUser: boolean }

Throws

credential_exists
unknown_user
malformed_response
configuration_error

Two ways to call it, and why they are separate

New account: by username

const { options } = await passkeys.startRegistration({
  username: 'ada',
});

Throws credential_exists if ada already exists.

Existing account: by session

const { options } = await passkeys.startRegistration({
  userId: req.session.userId,   // never from the body
});

Excludes credentials already on the account.

Why the account is not created until the ceremony finishes

What the options contain

{
  rp: { id: 'acme.com', name: 'Acme' },
  user: { id: '<base64url user handle>', name: 'ada', displayName: 'Ada Lovelace' },
  challenge: '<43 base64url characters>',
  pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
  timeout: 60000,
  excludeCredentials: [],
  authenticatorSelection: {
    residentKey: 'preferred',
    requireResidentKey: false,
    userVerification: 'preferred',
  },
  attestation: 'none',
}

Why excludeCredentials matters

finishRegistration

finishRegistrationmethod

finishRegistration(response: RegistrationResponseJSON): Promise<VerifyRegistrationResult>

Verifies the browser's response, creates the account if needed, and stores the credential.

Returns

{ verified: true; user; credential; isNewUser; attestation }
const result = await passkeys.finishRegistration(responseFromBrowser);
 
result.user;               // { id, username, displayName }
result.credential.id;      // base64url credential ID
result.credential.deviceType;  // 'multiDevice' | 'singleDevice'
result.isNewUser;          // true if this ceremony created the account
result.attestation.format; // 'none' for most consumer passkeys

The fifteen checks

In WebAuthn §7.1 order. Each one has a test that tampers with exactly that field and asserts the matching code.

#CheckFailure code
1Challenge exists, unexpired, consumed on readchallenge_not_found
2Challenge was issued for a registrationtype_mismatch
3Signed challenge equals the issued one, compared in constant timechallenge_mismatch
4clientData.type is webauthn.createtype_mismatch
5clientData.origin is allowedorigin_mismatch
6Not run inside a cross-origin frameorigin_mismatch
7rpIdHash equals SHA-256 of the configured rpIDrpid_mismatch
8User-present flag setuser_not_present
9User-verified flag set, when requireduser_not_verified
10Backup-state flag not set without backup-eligibleparse_error
11Attested credential data present and well formedparse_error
12rawId matches the credential ID inside the authenticator datamalformed_response
13Public key parses and its algorithm was offeredunsupported_algorithm
14Attestation statement verifies, if one was sentattestation_failed
15Credential ID is not already registered to anyonecredential_exists

Why check 15 spans all users, not just this one

Handling the result

app.post('/passkey/register/finish', async (req, res) => {
  try {
    const { user, isNewUser } = await passkeys.finishRegistration(req.body);
 
    req.session.userId = user.id;      // log them straight in
    res.json({ ok: true, isNewUser });
  } catch (error) {
    if (error instanceof PasskeyError) {
      return res.status(error.status).json(error.toJSON());
    }
    throw error;
  }
});

Or skip the route entirely and use an adapter.

Adding a second passkey

Worth prompting for at signup. A user with one device-bound passkey is one lost phone away from losing the account.

// Route protected by your existing session middleware.
app.post('/settings/passkeys/start', requireAuth, async (req, res) => {
  const { options } = await passkeys.startRegistration({
    userId: req.session.userId,
  });
  res.json(options);
});
// Browser, already signed in.
import { register } from 'passkify/client';
await register();   // no username: adds to the current account

Next