> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Passkey (WebAuthn)

> Use a WebAuthn credential as a step-up factor — verified by Prelude, no delegation hook involved.

Passkey is a managed step-up step: the WebAuthn assertion is verified by Prelude server-side against a credential the user registered earlier in their session. Unlike OTP steps which rely on a code delivered out-of-band, the passkey ceremony binds the proof to the origin (phishing-resistant) and to a private key that never leaves the user's authenticator.

| Step key         | Verified by  | Out-of-band channel | Phishing-resistant |
| ---------------- | ------------ | ------------------- | ------------------ |
| `verify_sms`     | Your backend | SMS                 | No                 |
| `verify_email`   | Your backend | Email               | No                 |
| `verify_passkey` | **Prelude**  | None (WebAuthn)     | **Yes**            |

No delegation hook is called for `verify_passkey`: the assertion is verified locally against the credential registered for the user. Sign-count monotonicity provides clone detection; the surrounding challenge token's JTI provides the anti-replay guarantee.

## Prerequisites

* A Prelude account with access to the Auth API
* An **Application ID** (`appID`) — see [Applications](/session/documentation/applications)
* Your **Management API key** for backend calls
* Step-up enabled on the application — see [Step-Up Authentication](/session/documentation/step-up-authentication)
* A working login flow (users must be authenticated before they can register a passkey)
* The frontend served over HTTPS, or `http://localhost:<port>` for local development — the WebAuthn API refuses any other origin

## How it works

```mermaid theme={null}
sequenceDiagram
  autonumber
  actor U as User (logged in)
  participant B as Browser (WebAuthn)
  participant SDK as Frontend SDK
  participant P as Prelude API

  rect rgba(76, 175, 80, 0.12)
  Note over U,P: Registration (one-time, in-session)
  U->>SDK: registerPasskey({ username, displayName })
  SDK->>P: POST /v1/session/me/passkeys/register/begin
  P-->>SDK: PublicKeyCredentialCreationOptions + registration_token
  SDK->>B: navigator.credentials.create({ publicKey })
  B-->>U: Authenticator prompt (Touch ID / YubiKey / ...)
  U-->>B: Approve
  B-->>SDK: AuthenticatorAttestationResponse
  SDK->>P: POST /v1/session/me/passkeys/register/finish (registration_token, attestation)
  P-->>P: Verify attestation, persist credential
  P-->>SDK: credential summary
  end

  rect rgba(33, 150, 243, 0.12)
  Note over U,P: Step-up (every time the scope is requested)
  U->>SDK: requestStepUp({ scope: "transfer:write" })
  SDK->>P: POST /v1/session/stepup/request
  P-->>SDK: challenge_token (current_step: verify_passkey)<br/>+ public_key_credential_request_options
  SDK->>B: navigator.credentials.get({ publicKey })
  B-->>U: Authenticator prompt
  U-->>B: Approve
  B-->>SDK: AuthenticatorAssertionResponse
  SDK->>P: POST /v1/session/stepup/continue (challenge_token, passkey_assertion)
  P-->>P: Verify assertion, advance sign count
  P-->>SDK: challenge_token (current_step: completed)
  SDK->>P: POST /v1/session/refresh
  P-->>SDK: access_token with granted scope
  end
```

Registration is a separate, in-session flow; the credentials it produces are reusable across as many step-up scopes as you configure with a `verify_passkey` step.

## Configure passkeys on the app

<Steps>
  <Step title="Set the Relying Party identity">
    The Relying Party (RP) identity is shared across every passkey ceremony on the application. Changing it after credentials are registered invalidates them at the authenticator layer, so set it once per environment.

    ```bash theme={null}
    curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
      -H "Content-Type: application/json" \
      -d '{
        "rp_id": "example.com",
        "rp_name": "Example",
        "allowed_origins": ["https://example.com", "https://app.example.com"],
        "user_verification": "required",
        "attestation_preference": "none",
        "login_enabled": false
      }'
    ```

    | Field                    | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
    | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `rp_id`                  | Yes      | The Relying Party identifier — the effective domain (no scheme, no port). Credentials are scoped to this RPID; changing it later breaks existing credentials.                                                                                                                                                                                                                                                                                                                                  |
    | `rp_name`                | Yes      | Human-readable display name shown by authenticators during ceremonies.                                                                                                                                                                                                                                                                                                                                                                                                                         |
    | `allowed_origins`        | Yes      | List of permitted origins (scheme + host + optional port). Must be a superset of the RPID. For local development, `http://localhost:<port>` is accepted.                                                                                                                                                                                                                                                                                                                                       |
    | `user_verification`      | No       | `required` (default), `preferred`, or `discouraged`. Use `required` for MFA so the ceremony proves something the user knows or is, not just possession.                                                                                                                                                                                                                                                                                                                                        |
    | `attestation_preference` | No       | `none` (default), `indirect`, `direct`, or `enterprise`. `none` is privacy-preserving and sufficient for most deployments.                                                                                                                                                                                                                                                                                                                                                                     |
    | `login_enabled`          | No       | Defaults to `false`. Set to `true` to opt the app into primary-factor (passwordless) passkey login at `/v1/session/login/passkey/{begin,finish}`. While the flag is on, every new registration also requests a discoverable credential (`residentKey: required`); authenticators that cannot store a resident key — most notably some older hardware security keys — will refuse the ceremony with `passkey_registration_failed`. The step-up / MFA path always works regardless of this flag. |
    | `aaguid_allowlist`       | No       | List of authenticator-model AAGUIDs (UUID strings) that registration is allowed to accept. Empty list disables the filter. When non-empty, registrations with a non-matching AAGUID are rejected with `passkey_authenticator_blocked`. Pairs naturally with `attestation_preference: "direct"` or `"enterprise"` — with `"none"` most authenticators return an all-zeros AAGUID and the allowlist matches nothing.                                                                             |
    | `aaguid_blocklist`       | No       | List of AAGUIDs to reject outright. Evaluated alongside `aaguid_allowlist` (blocklist wins). Typical use: blocklist `00000000-0000-0000-0000-000000000000` to refuse unattested credentials, or pin a specific known-bad authenticator out of an otherwise broad allow policy.                                                                                                                                                                                                                 |

    Enterprise example — accept only two authenticator models and explicitly reject unattested credentials:

    ```bash theme={null}
    curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
      -H "Content-Type: application/json" \
      -d '{
        "rp_id": "example.com",
        "rp_name": "Example",
        "allowed_origins": ["https://example.com"],
        "user_verification": "required",
        "attestation_preference": "direct",
        "aaguid_allowlist": [
          "ee882879-721c-4913-9775-3dfcce97072a",
          "08987058-cadc-4b81-b6e1-30de50dcbe96"
        ],
        "aaguid_blocklist": [
          "00000000-0000-0000-0000-000000000000"
        ]
      }'
    ```

    The blocklist is evaluated first; an AAGUID that's on it is rejected even when it also appears in the allowlist. Filters only run at **registration** time — credentials that were stored before the policy was set or changed remain valid for assertions, so tightening the policy doesn't retroactively lock anyone out. To enforce the new policy on the existing credential set, use the `DELETE /me/passkeys/{credentialID}` and re-registration flows.

    AAGUID lookup tip: the FIDO Alliance publishes a Metadata Service (MDS) mapping AAGUID → authenticator vendor / model. The values we accept here are the same canonical UUID strings the MDS uses, so a dashboard can resolve them to human-readable names on render.
  </Step>

  <Step title="Declare verify_passkey on the step-up configuration">
    Add the step key to your step-up configuration and reference it from any scope whose challenge should require a passkey.

    ```bash theme={null}
    curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
      -H "Content-Type: application/json" \
      -d '{
        "step_keys": [
          { "key": "verify_passkey", "description": "WebAuthn second factor" }
        ],
        "allowed_scopes": [
          {
            "scope": "transfer:write",
            "mode": "direct",
            "direct": {
              "identifier_types": ["email_address", "phone_number"],
              "status": "review",
              "grant_mode": "single-use",
              "granted_for": 600,
              "steps": [
                { "order": 1, "key": "verify_passkey", "expiration_duration": 60 }
              ]
            }
          }
        ]
      }'
    ```

    A scope may combine `verify_passkey` with other steps (OTP, custom). When `verify_passkey` is the current step, Prelude verifies locally; for any other step the regular dispatch applies.
  </Step>
</Steps>

## Fall back to OTP for users without a passkey

A registered passkey shows up on the user as a regular identifier of type `passkey`, alongside the user's email and phone identifiers. Direct-mode entries select on it via `identifier_types`, the same way they select on `email_address` or `phone_number`. List two entries on the same scope — the passkey-gated one first, the OTP fallback second — and the runtime serves the first one whose identifier types the user holds:

```json theme={null}
"allowed_scopes": [
  {
    "scope": "transfer:write",
    "mode": "direct",
    "direct": {
      "identifier_types": ["passkey"],
      "status": "review", "grant_mode": "single-use", "granted_for": 600,
      "steps": [{ "order": 1, "key": "verify_passkey", "expiration_duration": 60 }]
    }
  },
  {
    "scope": "transfer:write",
    "mode": "direct",
    "direct": {
      "identifier_types": ["email_address", "phone_number"],
      "status": "review", "grant_mode": "single-use", "granted_for": 600,
      "steps": [{ "order": 1, "key": "verify_sms", "expiration_duration": 300 }]
    }
  }
]
```

A user with a registered passkey matches the first entry and is routed to `verify_passkey`. A user without one falls through to the OTP entry. If neither entry's identifier types match (e.g. a user with no email/phone and no passkey), the request returns `passkey_step_unavailable`.

For customers running their own step-up backend, the delegation hook payload includes a `has_passkey` boolean as a convenience — your backend can route to `verify_passkey` when it's true without scanning the `identifiers` array for a `passkey` entry:

```json theme={null}
POST <your delegation hook>
{
  "scope_requested": "transfer:write",
  "user_id": "usr_...",
  "identifiers": [...],
  "has_passkey": true,
  "signals": {...},
  "metadata": {...}
}
```

Your hook can then return `verify_passkey` when `has_passkey` is true, falling back to an OTP step otherwise — see the [Step-Up Hook reference](/session/documentation/step-up-hook) for the response shape.

## Register a passkey

Registration runs **inside an authenticated session**, and the session must hold the `prld:passkey:write` scope — a fresh access token isn't enough. Drive the user through a step-up challenge that grants `prld:passkey:write` (typically an OTP step) just before enrolment so adding an authenticator always requires an additional ownership proof. The scope is single-use server-side and is atomically consumed when the credential is stored.

The two endpoints sit under `/v1/session/me/passkeys/register/`:

| Endpoint                            | Purpose                                                                                                            |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `POST /me/passkeys/register/begin`  | Issues the `PublicKeyCredentialCreationOptions` and a short-lived `registration_token`                             |
| `POST /me/passkeys/register/finish` | Verifies the attestation produced by the authenticator and persists the credential (consumes `prld:passkey:write`) |

A session without `prld:passkey:write` is rejected on `finish` with `403 insufficient_scope` — drive a step-up to grant the scope before retrying.

The frontend SDK wraps both in a single method:

```ts theme={null}
const { credential, alreadyRegistered } = await session.registerPasskey({
  username: "user@example.com",   // shown by the authenticator
  displayName: "User",            // optional, defaults to username
  nickname: "MacBook"             // optional, internal label
});

if (alreadyRegistered) {
  // This credential was already stored for the user — server returned a no-op success.
}
```

The `username` is the WebAuthn `user.name` (typically the user's email or phone). The `nickname` is server-side-only — useful to let users tell their credentials apart in a "Manage your passkeys" UI.

After a successful enrolment the SDK invalidates its cached session and refreshes it, so the next access token reflects the consumed scope and the (optionally mapped) `has_passkey` claim.

A user may register **more than one credential** (a platform passkey on their phone plus a hardware security key, for instance). The registration ceremony pre-populates `excludeCredentials` from the user's existing set, so an authenticator that already holds a credential is asked not to create a duplicate. When the authenticator honors this, the browser aborts the ceremony with `InvalidStateError`, which the SDK surfaces as a thrown `PasskeyRegistrationFailedError` — not a success.

The `alreadyRegistered: true` result is a separate, server-side path: if an attestation for an already-stored credential id does reach `finish`, Prelude returns the existing credential as a no-op success instead of overwriting it. Treat `alreadyRegistered` as the idempotent re-run signal, and catch `PasskeyRegistrationFailedError` for the duplicate-authenticator case.

### Surfacing passkey state to your frontend

If you'd rather let the frontend decide whether to prompt the user to register a passkey, expose the flag in the access token via [custom claims](/session/documentation/custom-claims) by mapping the built-in `has_passkey` input:

```json theme={null}
{
  "mapping": {
    "has_passkey": { "$input": "has_passkey", "$type": "bool" }
  }
}
```

The flag is recomputed on every access token issuance and flips on the active session's next refresh as soon as a credential is registered or removed — no extra round-trip needed.

## Manage registered passkeys

Three endpoints sit under `/v1/session/me/passkeys/` for the "Manage your passkeys" UI users build on top of the SDK:

| Endpoint                             | Purpose                                                                                                                                                                | Scope                 |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `GET /me/passkeys`                   | List every credential the user has registered, with nickname, transports, backup state, `created_at`, and `last_used_at`. Empty list (not 404) when the user has none. | Authenticated session |
| `PATCH /me/passkeys/{credentialID}`  | Rename a credential. Empty `nickname` clears the label. Cosmetic — no scope.                                                                                           | Authenticated session |
| `DELETE /me/passkeys/{credentialID}` | Remove a credential. The authenticated session is authoritative for managing its owner's credentials — no scope required.                                              | Authenticated session |

The SDK exposes these as `session.listPasskeys()`, `session.renamePasskey(credentialID, nickname)`, and `session.deletePasskey(credentialID)`. `deletePasskey` ends with an `invalidateCache()` + `refresh()` since removing a credential can flip the `has_passkey` claim.

```ts theme={null}
// "Manage your passkeys" — render the list.
const passkeys = await session.listPasskeys();

// Rename — no scope required.
await session.renamePasskey(credential.credential_id, "iPad");

// Delete — no scope required either.
await session.deletePasskey(credential.credential_id);
```

### Compromise response — wipe all passkeys on session revoke

`POST /v1/session/me/revoke?target=all` is the user-facing "sign me out everywhere" action and the typical compromise-response entry point. When that target is invoked, every passkey credential the user holds is wiped alongside the session revoke — the credential rows go, the `passkey` identifier rows go, and a `user.passkey.deleted` event fires per wiped credential. The next sign-in starts from a clean slate and the user re-enrols any device they still trust.

Routine cleanup (one stale device, no compromise) goes through `DELETE /me/passkeys/{credentialID}` instead — that path leaves other credentials and other sessions untouched. The wipe-on-revoke behaviour is intentionally scoped to `target=all`: `target=others` and `target=mine` leave passkeys in place because they aren't compromise responses (you're keeping the current device).

Admin-driven account deletion (`DELETE /v2/session/apps/{appID}/users/{userID}`) already cascades everything — sessions, identifiers, and the credential rows attached to them — through the user-deletion transaction, so no separate passkey step is needed there.

## Use the passkey in step-up

Once a credential is registered, calling `requestStepUp` for any scope whose first step is `verify_passkey` returns the WebAuthn assertion options alongside the challenge token. The SDK caches the options under the challenge id, so completing the ceremony is parameter-light:

```ts theme={null}
let challengeId: string | undefined;

await session.requestStepUp({
  scope: "transfer:write",
  onChallenge: (info) => {
    challengeId = info.challengeId;
    if (info.currentStep !== "verify_passkey") {
      // Fall back to OTP / custom step UI.
      return;
    }
  }
});

if (challengeId) {
  await session.continueWithPasskey({ challengeId });
  // session is refreshed; the access token now carries "transfer:write"
}
```

`continueWithPasskey` runs `navigator.credentials.get()` against the cached options, posts the assertion to `POST /v1/session/stepup/continue`, and lets the SDK refresh the session as usual. The scope is granted exactly when `current_step` reaches `completed`, with the lifetime / mode from the step-up configuration.

For browsers without WebAuthn support, gate the UI on the `isPasskeySupported()` helper:

```ts theme={null}
import { isPasskeySupported } from "@prelude.so/js-sdk";

if (!isPasskeySupported()) {
  // Skip passkey entirely; offer SMS / email step instead.
}
```

## Sign in with a passkey (primary factor)

A registered passkey can also stand on its own as the primary login factor — no email/phone OTP, no password. The ceremony uses WebAuthn discoverable credentials: the browser is given an empty `allowCredentials` list and surfaces every passkey it holds for the configured RPID. The selected authenticator's response carries the userHandle (= the user's UUID bytes) we set at registration, so the server resolves the user without ever asking the client for an identifier.

This flow is **opt-in per app**: set `login_enabled: true` on the `PasskeyConfig` first (`POST` or `PUT` `/v2/session/apps/{appID}/config/passkey`). Until that flag is on the endpoints reject every request with `passkey_not_configured`; the step-up / MFA flow keeps working regardless.

<Warning>
  **Existing credentials may not be discoverable.** Turning `login_enabled` on does not retroactively migrate credentials registered while it was off — those were created with the WebAuthn default (`residentKey: preferred`), which means platform passkeys (iCloud Keychain, Google, Microsoft, 1Password, ...) are typically discoverable but hardware security keys often are not. Affected users keep using step-up MFA without change, but to use the passwordless flow they need to register a new credential.
</Warning>

The endpoints sit under `/v1/session/login/passkey/`:

| Endpoint                     | Purpose                                                                                                                                                                                              |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /login/passkey/begin`  | Returns the `PublicKeyCredentialRequestOptions` (discoverable, no allowCredentials) and a short-lived `login_token`. Unauthenticated.                                                                |
| `POST /login/passkey/finish` | Verifies the assertion against the cached challenge, resolves the user from the assertion's userHandle, advances the credential's sign count, and returns a `challenge_token` for `/login/finalize`. |

The flow shares the same login-finalization step as the OTP / password paths — the token returned by `finish` is the standard login challenge token, redeemable for an access + refresh token via `POST /login/finalize`. Sessions minted through this path carry `login_method: "passkey"`.

```mermaid theme={null}
sequenceDiagram
  autonumber
  actor U as User (not yet signed in)
  participant B as Browser (WebAuthn)
  participant SDK as Frontend SDK
  participant P as Prelude API

  SDK->>P: POST /v1/session/login/passkey/begin
  P-->>SDK: PublicKeyCredentialRequestOptions + login_token
  SDK->>B: navigator.credentials.get({ publicKey, mediation: "required" })
  B-->>U: Authenticator prompt
  U-->>B: Approve
  B-->>SDK: AuthenticatorAssertionResponse (userHandle, signature)
  SDK->>P: POST /v1/session/login/passkey/finish (login_token, assertion)
  P-->>P: Verify assertion, resolve user from userHandle
  P-->>SDK: challenge_token (login_method: "passkey")
  SDK->>P: POST /v1/session/login/finalize
  P-->>SDK: access_token + refresh_token
```

Three notes worth flagging:

* **Discoverable credentials are required.** A passkey registered through `registerPasskey` already qualifies — the authenticator stores the user handle locally on the device — so existing credentials light up the login flow without re-enrolment.
* **Rate-limited.** `/login/passkey/begin` is unauthenticated, so the bucket is keyed on the app id (default: 600/min). A leaked client SDK can't spam begin ceremonies beyond the configured budget.
* **Conditional UI.** Pair the call with `navigator.credentials.get({ ..., mediation: "conditional" })` (via the SDK) to power passkey autofill on the username field. The same assertion shape works for both eager and conditional flows.

## What Prelude does

1. **Begin**: generates a 32-byte challenge, stashes the ceremony state under a single-use UUID in Redis (5-minute TTL, bound to the session), and returns the UUID as the `registration_token`. The state is GET-and-DEL'd on finish so the same token cannot be replayed. When `login_enabled` is on, the creation options also request `residentKey: required` so the resulting credential is discoverable.
2. **Finish**: validates the attestation / assertion against the cached challenge, the configured RPID, and the allowed origins. Rejects ceremonies whose origin or RPID do not match, and atomically consumes the session's `prld:passkey:write` scope as part of the credential write.
3. **Persists** the credential (one row per user, per credential id) with the COSE-encoded public key, the authenticator-reported sign count, transports, AAGUID, and the backup-eligible / backup-state flags.
4. On every assertion, **advances the sign count** through a conditional update that rejects any non-monotonic value as a clone signal — surfaced as `passkey_step_unavailable` so the host app can fall back without exposing implementation details.
5. **Anti-replay** on the surrounding challenge token's JTI, exactly like the OTP steps. The challenge token cannot be reused once the assertion has succeeded.

## Constraints

| Rule                         | Limit                                                                                                                                                                                                                                                       |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rp_id`                      | Required. The effective domain; no scheme, no port. Must match every origin in `allowed_origins`.                                                                                                                                                           |
| `allowed_origins`            | At least one. Each must be a valid scheme + host (`https://...` in production; `http://localhost:<port>` is accepted in non-prod for local development).                                                                                                    |
| `user_verification`          | One of `required` / `preferred` / `discouraged`. Defaults to `required`.                                                                                                                                                                                    |
| `attestation_preference`     | One of `none` / `indirect` / `direct` / `enterprise`. Defaults to `none`.                                                                                                                                                                                   |
| `login_enabled`              | Boolean. Defaults to `false`. Required to be `true` for `/v1/session/login/passkey/{begin,finish}` to accept traffic; the step-up / MFA endpoints ignore the flag.                                                                                          |
| Discoverable credentials     | When `login_enabled` is `true`, registration requires the authenticator to create a resident (discoverable) credential. Excludes some older hardware security keys without resident-key storage.                                                            |
| AAGUID allowlist / blocklist | Optional. UUID-canonical strings. When `aaguid_allowlist` is non-empty, registration is restricted to those authenticator models; `aaguid_blocklist` rejects matches outright. Only enforced at registration — existing credentials are never re-evaluated. |
| Registration token TTL       | 5 minutes. The same token cannot be reused after the ceremony completes.                                                                                                                                                                                    |
| Step `expiration_duration`   | Same step-up constraint: 0–86400 seconds, but keep it short (60 s is a generous WebAuthn timeout).                                                                                                                                                          |
| Credentials per user         | No hard limit. Encourage users to register at least two (e.g. platform passkey + hardware key) so losing one device does not lock them out.                                                                                                                 |

## Webhook events

The passkey ceremonies emit three event types you can subscribe to on the app webhook configuration. The payloads mirror the `user.identifier.*` events and the dashboard's user-history feed ingests them so customer-visible audit timelines surface passkey activity.

| Event                           | When                                                                                                                                                                                                  | Payload highlights                                                                                                                                                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `user.passkey.registered`       | A user finishes the WebAuthn registration ceremony. The "we added a new passkey to your account — wasn't you?" notification flow keys on this event. Idempotent re-registration does **not** re-emit. | `user_id`, `session_id`, `credential.credential_id`, `credential.nickname`, `credential.transports`, `credential.backup_state`, `registered_at`                                                                          |
| `user.passkey.deleted`          | A user removes a credential via `DELETE /me/passkeys/{id}`.                                                                                                                                           | `user_id`, `session_id`, `credential.*` (snapshot from just before the delete), `deleted_at`                                                                                                                             |
| `user.passkey.assertion_failed` | An assertion fails to verify — bad signature, sign-count regression, expired login token, unknown user, etc. Useful for brute-force / cloned-authenticator detection.                                 | `user_id` (best-effort), `session_id` (best-effort), `credential_id` (best-effort), `source` (`step_up`\|`login`), `reason` (`invalid_assertion`\|`sign_count_replay`\|`token_invalid`\|`no_credentials`), `occurred_at` |

The `reason` field on `user.passkey.assertion_failed` is the typed `PasskeyAssertionFailureReason` so subscribers can branch without parsing free-form messages. `credential_id` is empty on early failures (e.g. unknown login token) and present once the WebAuthn library has resolved the credential.

## Errors

| Status | Code                            | Cause                                                                                                                                                                                                        |
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400    | `bad_request`                   | Missing or malformed registration body.                                                                                                                                                                      |
| 400    | `passkey_registration_failed`   | The attestation could not be verified — typically a bad challenge, mismatched origin, or excluded credential.                                                                                                |
| 400    | `passkey_authenticator_blocked` | Registration refused because the credential's AAGUID is missing from `aaguid_allowlist` or present in `aaguid_blocklist`. Route the user to a different authenticator (or relax the policy).                 |
| 400    | `passkey_step_unavailable`      | The user has no registered credentials, the assertion failed verification (including sign-count regression — possible cloned authenticator), or the request supplied no assertion payload.                   |
| 401    | `unauthorized`                  | `/login/passkey/finish` — the login token is unknown / expired, or the assertion failed verification. Indistinguishable from "no matching credential" by design so an attacker cannot probe for valid users. |
| 403    | `insufficient_scope`            | The session does not hold `prld:passkey:write`. Run a step-up to grant the scope before retrying registration.                                                                                               |
| 404    | `not_found`                     | `GET`, `PATCH`, or `DELETE` `/me/passkeys/{credentialID}` — no credential matches the (user, credentialID) pair.                                                                                             |
| 429    | `rate_limited`                  | `/passkey/register/begin` or `/login/passkey/begin` — too many ceremonies launched in the rate-limit window. Honor `Retry-After` and back off.                                                               |
| 403    | `passkey_not_configured`        | The application has no `PasskeyConfig`. Configure the Relying Party first.                                                                                                                                   |

When you receive `passkey_step_unavailable`, route the user to a fallback step (SMS OTP, email OTP, custom step). The SDK exposes a dedicated `PasskeyStepUnavailableError` and a `PasskeyNotSupportedError` for browsers without WebAuthn.

## Security recommendations

<Warning>
  **Use `user_verification: "required"` for MFA.** Without it, the ceremony only proves possession of the authenticator, not that the user was present and verified — which collapses passkey to a "something you have" factor instead of "something you have + something you are/know".
</Warning>

* **Encourage backup credentials.** A user with a single device-bound credential (hardware key, non-syncing platform passkey) cannot recover if they lose that device. Let them register more than one in your account-management UI.
* **Treat sign-count regressions as security incidents.** Prelude rejects them automatically, but if your fraud telemetry shows a credential repeatedly hitting `passkey_step_unavailable` after previously working, investigate.
* **Rotate the Relying Party with care.** Changing `rp_id` invalidates every existing credential. If you must, communicate it to users in advance and let them re-register before deprecating the old RPID.

## What's next?

<CardGroup cols={2}>
  <Card title="Step-Up Authentication" icon="shield-check" href="/session/documentation/step-up-authentication">
    Full step-up overview, including custom scopes and the delegation hook.
  </Card>

  <Card title="Custom Steps" icon="puzzle-piece" href="/session/documentation/step-up-custom-steps">
    Add client-owned steps (KYC, biometric, ...) to your challenges.
  </Card>

  <Card title="Web SDK Step-Up Guide" icon="code" href="/session/documentation/frontend-sdks/web/step-up">
    Full code path for the JS SDK, including the passkey methods.
  </Card>
</CardGroup>
