# 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

### startRegistration
"
>

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

  
- **username** (`string`) — 
    The username for a **new** account. Mutually exclusive with `userId`.
  
  
- **displayName** (`string`, default `username`) — 
    Shown 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.
  
  
- **userId** (`string`) — 
    An 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 value`, required) — 
    Override 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.

- `credential_exists` — 
The username is already taken. See below.

- `unknown_user` — 
`userId` was given but no such account exists.

- `malformed_response` — 
Neither `username` nor `userId` was supplied.

- `configuration_error` — 
The 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

**Do this — New account: by username:**

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

Throws `credential_exists` if `ada` already exists.

**Do this — Existing account: by session:**

```js
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](/docs/server/adapters) 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

```js
{
  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`](/docs/errors#already_registered). Treat that as
information, not an error: show "this device already has a passkey for your
account".

## finishRegistration

### finishRegistration
"
>

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.

```js
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.

| # | 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

```js
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](/docs/server/adapters).

## 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.

```js
// 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);
});
```

```js
// Browser, already signed in.
import { register } from 'passkify/client';
await register();   // no username: adds to the current account
```

## Next

- [Authentication](/docs/server/authentication) for the login side.
- [Credential management](/docs/server/credentials) to list and revoke.
- [Errors](/docs/errors) for every code above.
