# Passkeys (/docs/authentication/passkeys)

> Register passkeys, sign in with them (including discoverable autofill sign-in), use them as a second factor, and let people name and manage them.



Passwords and one-time codes can be typed into a convincing fake login page. Passkeys
cannot: they are WebAuthn credentials, a key pair kept on a device or synced by a platform account, and the browser
only uses them on your own site. They also verify the person on the device (with biometrics or a PIN), so one
passkey is both factors at once.

In Better IAM a passkey can sign a person in on its own, and satisfies MFA when it does, or it can answer the second
step of a password or passwordless sign-in. Use passkeys when you want phishing-resistant sign-in, or as a faster
second factor than an authenticator code.

## Setup [#setup]

      ### Configure the relying party [#configure-the-relying-party]

    WebAuthn ties every passkey to a **relying party**: the site it may be used on, identified by a domain (the RP ID).
    Set it under `authentication.passkeys`:

    ```ts title="iam.ts"
    import { betterIam } from 'better-iam';
    import { sqliteAdapter } from 'better-iam/adapter-sqlite';

    export const iam = betterIam({
      database: sqliteAdapter({ filename: './iam.db' }),
      secret: process.env.BETTER_IAM_SECRET,
      baseURL: 'https://app.example.com',
      trustedOrigins: ['https://admin.example.com'],
      authentication: {
        passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' },
      },
    });
    ```

    * `rpID` must match every trusted origin: each origin's hostname must equal it or be a subdomain of it.
      Construction fails with `INVALID_CONFIG` otherwise.
    * `rpName` is the name authenticators show; it defaults to `authentication.appName` ("Better IAM" by default).
    * Without `passkeys`, every passkey call fails with `FEATURE_DISABLED`.
    * A request served on an organization's [custom hostname](/docs/operations/deployment/hosts-and-regions) outside the RP ID (such as
      `login.acme.com` for passkeys of `example.com`) also fails with `FEATURE_DISABLED`, because the browser would refuse
      the ceremony there.
  
      ### Migrate [#migrate]

    Passkeys need no migration of their own: they are stored in the document store that
    `npx better-iam migrate` created (see [Schema](#schema)). Run it once if you have not:

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    ```
  
      ### Allow the method [#allow-the-method]

    A tenant whose [authentication policy](/docs/authentication/tenant-policy#restricting-sign-in-methods) sets
    `allowedMethods` must include `passkey`, or passkey sign-in is refused with `METHOD_NOT_ALLOWED`. Tenants without
    `allowedMethods` accept every enabled method.
  
      ### Add the browser helpers [#add-the-browser-helpers]

    The WebAuthn helpers live in `better-iam/client/passkeys`, a separate entry point so the core client does not bundle
    them:

    ```ts title="lib/iam-client.ts"
    import { createIamClient } from 'better-iam/client';
    import type { iam } from './iam';

    export const client = createIamClient<typeof iam>();
    ```

    | Helper                             | What it does                                                                                                                                     |
    | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
    | `startRegistration`                | Asks the authenticator to create a passkey from server-issued registration options.                                                              |
    | `startAuthentication`              | Asks the authenticator to sign server-issued options with an existing passkey; `useBrowserAutofill: true` offers passkeys in the username field. |
    | `browserSupportsWebAuthn`          | Whether the browser supports passkeys at all, so you can hide the button when it does not.                                                       |
    | `browserSupportsWebAuthnAutofill`  | Whether the browser can offer passkeys through autofill.                                                                                         |
    | `platformAuthenticatorIsAvailable` | Whether the device has a built-in authenticator (such as a fingerprint reader).                                                                  |
    | `WebAuthnAbortService`             | Cancels a waiting ceremony, for example an autofill request you want to restart.                                                                 |
  
## Usage [#usage]

### Register a passkey [#register-a-passkey]

People add passkeys from their account page, while signed in with a recently authenticated session. Recent
authentication stops someone who finds an unlocked laptop from adding their own passkey to the account.

      #### Get registration options [#get-registration-options]

    `auth.beginPasskeyRegistration` creates a one-time challenge and the WebAuthn options that describe the passkey to
    create.

          **Client:**

        ```ts
        import { client } from './lib/iam-client';

        const { challengeId, options } = await client.auth.beginPasskeyRegistration();
        ```
      
      **Server:**

        ```ts
        import { iam } from './iam';

        const { challengeId, options } = await iam.api.auth.beginPasskeyRegistration({ headers: request.headers });
        ```
          
    **Route:** `POST /api/iam/auth/beginPasskeyRegistration`

    The options require a discoverable credential (resident key) and user verification, request no attestation, and
    exclude the passkeys the person already has. The challenge is bound to this session and valid for five minutes.
  
      #### Run the browser ceremony [#run-the-browser-ceremony]

    The browser shows its passkey dialog, the person confirms with their fingerprint, face, or PIN, and the
    authenticator creates the key pair.

    ```ts
    import { startRegistration } from 'better-iam/client/passkeys';

    const response = await startRegistration({ optionsJSON: options });
    ```
  
      #### Finish and name it [#finish-and-name-it]

    `auth.finishPasskeyRegistration` sends the authenticator's response back, with an optional name the person will
    recognize later in their list.

          **Client:**

        ```ts
        const { id, name } = await client.auth.finishPasskeyRegistration({
          challengeId,
          response,
          name: 'Work laptop', // optional, at most 64 characters
        });
        ```
      
      **Server:**

        ```ts
        const { id, name } = await iam.api.auth.finishPasskeyRegistration(
          { headers: request.headers },
          { challengeId, response, name: 'Work laptop' },
        );
        ```
          
    **Route:** `POST /api/iam/auth/finishPasskeyRegistration`
  
The server verifies the challenge, origin, RP ID, and user verification, then stores the public key. Without a
name, the passkey is labeled from what the authenticator reports about itself: "This device" for a built-in
authenticator, "Phone" for a cross-device (hybrid) one, "Security key" for USB, NFC, or Bluetooth keys, and
"Passkey" otherwise. Registration is audited as `auth:passkey:create` with the name.

A credential is unique for the relying party across the whole installation, including across tenant accounts:
registering the same credential twice fails with `PASSKEY_EXISTS`.

### Sign in with a passkey [#sign-in-with-a-passkey]

Signing in with a passkey takes two calls around the browser ceremony. `auth.beginPasskeyAuthentication` issues a
challenge and the options for the browser, and `auth.finishPasskeyAuthentication` verifies the signed response and
issues a session that has already passed MFA (`method: 'passkey'`). Through the HTTP handler the session is set as
the session cookie (`__Host-better-iam.session` on HTTPS, `better-iam.session` on loopback HTTP).

  **Discoverable (autofill):**

    Without an `email`, the options name no credential. The authenticator offers whatever discoverable passkey it holds
    for your relying party, through the browser's passkey picker or autofill, and the server finds the account from
    the credential itself.

    ```tsx
    // <input name="email" autoComplete="username webauthn" /> on the login form enables autofill.
    import { browserSupportsWebAuthnAutofill, startAuthentication } from 'better-iam/client/passkeys';
    import { client } from './lib/iam-client';

    if (await browserSupportsWebAuthnAutofill()) {
      const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId });
      const response = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true });
      await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response });
    }
    ```
  
  **With an email:**

    When your login form asks for the email first, pass it along: the options then list that person's passkeys, so
    the browser only offers theirs.

    ```ts
    import { startAuthentication } from 'better-iam/client/passkeys';
    import { client } from './lib/iam-client';

    const { challengeId, options } = await client.auth.beginPasskeyAuthentication({
      tenantId,
      email: 'alice@example.com',
    });
    const response = await startAuthentication({ optionsJSON: options });
    await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response });
    ```

    An unknown or inactive address, or an account without a passkey, is not refused here: the server answers with
    decoy options (a credential ID that is stable per address) so the response never reveals who has an account. The
    ceremony then cannot finish, and `finishPasskeyAuthentication` fails with `INVALID_CHALLENGE`.
  
  **Server:**

    Both calls are public, so a server that proxies the ceremony calls them without a credential. A direct call returns
    the session token; only the HTTP handler sets cookies.

    ```ts
    import { iam } from './iam';

    const { challengeId, options } = await iam.api.auth.beginPasskeyAuthentication({ tenantId });
    // ...send options to the browser, receive its response...
    const { token, session } = await iam.api.auth.finishPasskeyAuthentication({ tenantId, challengeId, response });
    ```
  
**Routes:** `POST /api/iam/auth/beginPasskeyAuthentication`, `POST /api/iam/auth/finishPasskeyAuthentication`

* The sign-in challenge is valid for five minutes. An autofill request can wait longer than that, so start a fresh
  one before it expires (the console refreshes after four minutes).
* Discovery names nobody, so it is rate limited per client address, with ten times the ordinary allowance, because
  login pages start one on every visit.
* A discovered passkey that is not registered in this organization, a user handle that does not match the account,
  or a failed verification is refused with `INVALID_PASSKEY`.
* The server checks the challenge, origin, RP ID, user verification, the signature, and the signature counter, and
  records the passkey's `lastUsedAt`.

### Use a passkey as a second factor [#use-a-passkey-as-a-second-factor]

People who still sign in with a password can use their passkey as the second step, which is faster than typing a
code and cannot be phished. When a password or passwordless sign-in returns `mfaRequired` with
`passkeyAvailable: true` (the person has at least one passkey), `auth.beginPasskeyMfa` issues WebAuthn options
bound to that sign-in challenge, and `auth.finishPasskeyMfa` verifies the response and issues the session:

  **Client:**

    ```ts
    import { startAuthentication } from 'better-iam/client/passkeys';
    import { client } from './lib/iam-client';

    const { challengeId, options } = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge });
    const response = await startAuthentication({ optionsJSON: options });
    const session = await client.auth.finishPasskeyMfa({
      tenantId,
      challengeId,
      response,
      rememberDevice: true, // optional; see "remember this device"
    });
    ```
  
  **Server:**

    ```ts
    import { iam } from './iam';

    const { challengeId, options } = await iam.api.auth.beginPasskeyMfa({ tenantId, challenge });
    const { token, session, deviceToken } = await iam.api.auth.finishPasskeyMfa({
      tenantId,
      challengeId,
      response,
      rememberDevice: true,
    });
    ```
  
**Routes:** `POST /api/iam/auth/beginPasskeyMfa`, `POST /api/iam/auth/finishPasskeyMfa`

The assertion is verified like a passkey sign-in (challenge, origin, RP ID, user verification, signature, counter)
and bound to the pending sign-in challenge, which must still be open and belong to the same person
(`INVALID_CHALLENGE` otherwise). Both challenges are consumed together. The session keeps the method of the first
factor, such as `password`. Without a registered passkey, `beginPasskeyMfa` fails with `FEATURE_DISABLED`. With
`rememberDevice: true`, the response can carry a `deviceToken`; see
[remember this device](/docs/authentication/mfa#remember-this-device).

### List, rename, and delete passkeys [#list-rename-and-delete-passkeys]

People collect passkeys on several devices over time, and need to recognize and remove old ones, such as the
passkey on a phone they sold. Three calls back an account page:

* `auth.listPasskeys` returns the caller's passkeys, newest first, without key material (fields below).
* `auth.renamePasskey` gives a passkey a label the person recognizes, such as "YubiKey 5C". Audited as
  `auth:passkey:rename`.
* `auth.deletePasskey` removes one. It needs recent authentication, ends every session of the person, and is
  audited as `auth:passkey:delete`.

  **Client:**

    ```ts
    import { client } from './lib/iam-client';

    const passkeys = await client.auth.listPasskeys();
    await client.auth.renamePasskey({ id: passkeys[0].id, name: 'YubiKey 5C' });
    await client.auth.deletePasskey({ id: passkeys[1].id });
    ```
  
  **Server:**

    ```ts
    import { iam } from './iam';

    const credential = { headers: request.headers };
    const passkeys = await iam.api.auth.listPasskeys(credential);
    await iam.api.auth.renamePasskey(credential, { id: passkeys[0].id, name: 'YubiKey 5C' });
    await iam.api.auth.deletePasskey(credential, { id: passkeys[1].id });
    ```
  
**Routes:** `POST /api/iam/auth/listPasskeys`, `POST /api/iam/auth/renamePasskey`, `POST /api/iam/auth/deletePasskey`

Each listed passkey carries:

| Field                     | Meaning                                                                                  |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `id`, `name`              | The passkey's ID and label (at most 64 characters).                                      |
| `credentialId`            | The WebAuthn credential ID.                                                              |
| `createdAt`, `lastUsedAt` | When it was registered, and last used to sign in or answer MFA.                          |
| `deviceType`              | `singleDevice` (bound to one authenticator) or `multiDevice` (synced).                   |
| `backedUp`                | Whether the credential is backed up, as reported at registration.                        |
| `transports`              | How the browser can reach the authenticator (`internal`, `hybrid`, `usb`, `nfc`, `ble`). |
| `aaguid`                  | The authenticator model identifier, when the authenticator reports one.                  |

Removing the last passkey is refused with `LAST_AUTHENTICATOR` unless the person has another way to sign in: a
password, or a verified email or phone number with passwordless sign-in enabled. That keeps people from locking
themselves out. A passkey ID that is not the caller's fails with `NOT_FOUND`.

`auth.mfaStatus()` reports how many passkeys a person has, and `tenants.usage` counts people with an authenticator
or a passkey as MFA-enrolled.

### Require passkeys [#require-passkeys]

To make an organization phishing-resistant, set its policy's `allowedMethods` to `['passkey']`. Password,
passwordless, and federated sign-in are then refused with `METHOD_NOT_ALLOWED` before any credential is examined.
Policies can also demand a passkey session for one action only, with a condition on `principal.authMethod`:

```ts title="A statement that requires a passkey session"
{
  effect: 'allow',
  actions: ['billing:refund'],
  resources: ['invoice/*'],
  conditions: { StringEquals: { 'principal.authMethod': 'passkey' } },
}
```

## Options [#options]

| Option                                  | Type                 | Default                  | Description                                                                                              |
| --------------------------------------- | -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- |
| `authentication.passkeys.rpID`          | `string`             | none (passkeys off)      | The relying-party domain. Every `baseURL` and `trustedOrigins` hostname must equal it or be a subdomain. |
| `authentication.passkeys.rpName`        | `string`             | `authentication.appName` | The name authenticators show.                                                                            |
| `authentication.appName`                | `string`             | `'Better IAM'`           | Product name; the default `rpName`.                                                                      |
| `baseURL`, `trustedOrigins`             | `string`, `string[]` | required, `[]`           | The origins allowed to run passkey ceremonies.                                                           |
| `authentication.recentAuthenticationMs` | `number`             | 5 minutes                | How recent a session must be to register or delete a passkey.                                            |
| `authentication.rateLimits.attempts`    | `number`             | `10`                     | Attempts per window for passkey sign-in with an email; discovery gets ten times this per client address. |
| Tenant `allowedMethods`                 | `AuthMethod[]`       | every enabled method     | Must include `passkey` for passkey sign-in in that tenant.                                               |

## Schema [#schema]

Passkeys and their ceremonies are stored in two collections of the document store:

| Collection       | Key fields                                                                                                                                                  | Notes                                                                                       |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `authPasskeys`   | `id`, `tenantId`, `identityId`, `credentialId`, `publicKey`, `counter`, `transports`, `name`, `createdAt`, `lastUsedAt`, `deviceType`, `backedUp`, `aaguid` | One record per registered passkey. `publicKey` and `counter` are never returned by the API. |
| `authChallenges` | `id`, `tenantId`, `identityId`, `purpose`, `tokenHash`, `expiresAt`, `payload`, `sessionId`                                                                 | Single-use registration and sign-in challenges, valid for five minutes, stored hashed.      |

See [Database schema](/docs/databases/schema) for every collection.

## Errors [#errors]

| Code                                                                  | When                                                                                                                                                 |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`FEATURE_DISABLED`](/docs/reference/errors#feature_disabled)         | `authentication.passkeys` is not set, the request's hostname is outside the RP ID, or `beginPasskeyMfa` is called for a person without a passkey.    |
| [`INVALID_CONFIG`](/docs/reference/errors#invalid_config)             | `rpID` does not match `baseURL` or a trusted origin (at construction).                                                                               |
| [`PASSKEY_EXISTS`](/docs/reference/errors#passkey_exists)             | The credential is already registered.                                                                                                                |
| [`INVALID_PASSKEY`](/docs/reference/errors#invalid_passkey)           | Registration or authentication verification failed, or the passkey does not belong to this account or organization.                                  |
| [`INVALID_CHALLENGE`](/docs/reference/errors#invalid_challenge)       | The passkey challenge, or the pending sign-in challenge it answers, is invalid or expired; also a finish after decoy options for an unknown `email`. |
| [`METHOD_NOT_ALLOWED`](/docs/reference/errors#method_not_allowed)     | The tenant's `allowedMethods` excludes `passkey`.                                                                                                    |
| [`RECENT_AUTH_REQUIRED`](/docs/reference/errors#recent_auth_required) | Registering or deleting without a recently authenticated session.                                                                                    |
| [`LAST_AUTHENTICATOR`](/docs/reference/errors#last_authenticator)     | Deleting the only passkey of a person with no other way to sign in.                                                                                  |
| [`RATE_LIMITED`](/docs/reference/errors#rate_limited)                 | Too many attempts in the window.                                                                                                                     |
| [`NOT_FOUND`](/docs/reference/errors#not_found)                       | Renaming or deleting a passkey that is not the caller's.                                                                                             |

## Next steps [#next-steps]

  - [Multi-factor authentication](/docs/authentication/mfa): Challenges, authenticators, recovery codes, and remembered devices.

  - [Sign-in methods](/docs/authentication/sign-in-methods): Passwords, passwordless, federation, and API keys.

  - [Tenant policy](/docs/authentication/tenant-policy): Restrict an organization to passkeys.

  - [Sessions](/docs/authentication/sessions): What a passkey session carries and how it ends.
