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.
usernamestringThe username for a new account. Mutually exclusive with userId.
displayNamestringdefault usernameShown in the operating system's passkey picker. This is the label a user sees months later when choosing between saved passkeys, so use something human.
userIdstringAn existing account's user handle, taken from the caller's authenticated session, to let a signed-in user add another device.
userVerification'required' | 'preferred' | 'discouraged'default config valueOverride the configured policy for this one ceremony. Recorded with the
challenge, so finishRegistration enforces the same policy the ceremony
was started under.
Returns
{ options: RegistrationOptionsJSON; userId: string; isNewUser: boolean }userId is the handle these options were issued for. isNewUser is true
when a successful ceremony will create an account.
Throws
credential_existsThe username is already taken. See below.
unknown_useruserId was given but no such account exists.
malformed_responseNeither username nor userId was supplied.
configuration_errorThe account's ID exceeds 64 bytes when UTF-8 encoded, which WebAuthn does not allow for a user handle.
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.
Never let an unauthenticated request name the account
If a request body can say which existing account a new passkey attaches to, then anyone who knows a username owns that account: they register their own passkey against it and sign in as its owner.
This is the single most common way to get a passkey integration
catastrophically wrong. passkify makes it hard to express: the username form
refuses existing accounts outright, and the
mounted routes ignore a username in the body whenever
a session is present.
Why the account is not created until the ceremony finishes
startRegistration({ username }) does not touch your users table. It mints a
user handle, records it with the challenge, and returns.
If it created the row up front, every abandoned prompt, and users abandon plenty, would leave a half-registered account with no credential attached. Those accounts are worse than useless: they occupy the username, so the user who closed the dialog by accident can never sign up with it again.
The account is created inside finishRegistration, after every check passes.
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
It lists the credentials the account already has, so the authenticator can refuse to create a duplicate. Without it, a user who taps "register" twice ends up with two passkeys on the same device, and the picker at login shows two identical-looking entries.
When the authenticator declines, the browser raises InvalidStateError, which
passkify/client surfaces as
already_registered. Treat that as
information, not an error: show "this device already has a passkey for your
account".
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 }Always verified: true. Any failure throws instead.
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 passkeysThe checks
Each one has a test that tampers with exactly that field and asserts the matching code.
| # | Check | Failure code | Specification |
|---|---|---|---|
| 1 | The response has the fields it must have | malformed_response | WebAuthn §7.1 step 1 |
| 2 | The challenge is one we issued and have not yet consumed | challenge_not_found | WebAuthn §7.1 step 8 |
| 3 | The challenge was issued for a registration, not a login | type_mismatch | WebAuthn §7.1 step 8 |
| 4 | The signed challenge equals the stored one | challenge_mismatch | WebAuthn §7.1 step 8 |
| 5 | clientData.type is exactly "webauthn.create" | type_mismatch | WebAuthn §7.1 step 7 |
| 6 | clientData.origin is in the allow-list | origin_mismatch | WebAuthn §7.1 step 9 |
| 7 | The ceremony did not run in a cross-origin frame | origin_mismatch | WebAuthn §7.1 step 10 |
| 8 | The attestation object has fmt, attStmt and authData | parse_error | WebAuthn §7.1 step 12 |
| 9 | The RP ID hash matches this relying party | rpid_mismatch | WebAuthn §7.1 step 13 |
| 10 | The user-present flag is set | user_not_present | WebAuthn §7.1 step 14 |
| 11 | The user was verified, when the ceremony required it | user_not_verified | WebAuthn §7.1 step 15 |
| 12 | The backup-state flag is not set without backup-eligible | parse_error | WebAuthn §6.1.3 |
| 13 | The credential is backup-eligible, when the site requires it | attestation_failed | passkify policy |
| 14 | The response carries attested credential data | parse_error | WebAuthn §7.1 step 12 |
| 15 | rawId equals the credential ID inside the authenticator data | parse_error | WebAuthn §7.1 step 12 |
| 16 | The public key uses an algorithm we offered | unsupported_algorithm | WebAuthn §7.1 step 16 |
| 17 | The attestation statement, if present, is internally consistent | attestation_failed | WebAuthn §7.1 step 19 |
| 18 | The credential is not already registered, to anyone | credential_exists | WebAuthn §7.1 step 22 |
Why the last check spans all users, not just this one
A credential ID is globally unique by construction. If one arrives that is already in your database under a different account, something is wrong that a per-user check would miss entirely: a replayed registration, a store bug, or a deliberate attempt to bind one authenticator to two accounts.
Scoping the uniqueness check to the current user would let that through silently, and the second account would then be signed into by whoever holds the first credential.
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 accountNext
- Authentication for the login side.
- Credential management to list and revoke.
- Errors for every code above.