Registration

startRegistration and finishRegistration, and the eighteen 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 checks

Each one has a test that tampers with exactly that field and asserts the matching code.

18 checks, in WebAuthn §7.1 order
#CheckFailure codeSpecification
1The response has the fields it must havemalformed_responseWebAuthn §7.1 step 1
2The challenge is one we issued and have not yet consumedchallenge_not_foundWebAuthn §7.1 step 8
3The challenge was issued for a registration, not a logintype_mismatchWebAuthn §7.1 step 8
4The signed challenge equals the stored onechallenge_mismatchWebAuthn §7.1 step 8
5clientData.type is exactly "webauthn.create"type_mismatchWebAuthn §7.1 step 7
6clientData.origin is in the allow-listorigin_mismatchWebAuthn §7.1 step 9
7The ceremony did not run in a cross-origin frameorigin_mismatchWebAuthn §7.1 step 10
8The attestation object has fmt, attStmt and authDataparse_errorWebAuthn §7.1 step 12
9The RP ID hash matches this relying partyrpid_mismatchWebAuthn §7.1 step 13
10The user-present flag is setuser_not_presentWebAuthn §7.1 step 14
11The user was verified, when the ceremony required ituser_not_verifiedWebAuthn §7.1 step 15
12The backup-state flag is not set without backup-eligibleparse_errorWebAuthn §6.1.3
13The credential is backup-eligible, when the site requires itattestation_failedpasskify policy
14The response carries attested credential dataparse_errorWebAuthn §7.1 step 12
15rawId equals the credential ID inside the authenticator dataparse_errorWebAuthn §7.1 step 12
16The public key uses an algorithm we offeredunsupported_algorithmWebAuthn §7.1 step 16
17The attestation statement, if present, is internally consistentattestation_failedWebAuthn §7.1 step 19
18The credential is not already registered, to anyonecredential_existsWebAuthn §7.1 step 22

Why the last check 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