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

> Configure WebAuthn passkey authentication for MFA step-up or passwordless sign-in.

This guide walks you through configuring passkey authentication on your application using Prelude Auth. Passkeys can serve as an MFA step-up factor (the default) and, optionally, as a primary-factor passwordless sign-in method.

For the full conceptual reference — ceremony shape, security model, error catalogue — see the [Passkey](/auth/documentation/passkey) page.

## Prerequisites

Before you start, make sure you have:

* A Prelude account with access to Prelude Auth
* An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications)
* Your **Management API key** for backend calls
* A frontend served over HTTPS, or `http://localhost:<port>` for local development — the WebAuthn API refuses any other origin

## Set up passkey authentication

Configure the WebAuthn Relying Party identity for your app. The RP identity is shared across every passkey ceremony — changing it after credentials are registered invalidates them at the authenticator layer, so set it once per environment.

<Steps>
  <Step title="Create a passkey configuration">
    ```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. Required `direct` or `enterprise` when you want to enforce the AAGUID allowlist below.                     |
    | `login_enabled`          | No       | Defaults to `false`. Set to `true` to opt the app into primary-factor passwordless passkey login. See [Enable passwordless login](#enable-passwordless-login-optional) below.                                                         |
    | `granted_scopes`         | No       | Session scopes attached to the session minted by a primary-factor passkey login, mirroring the per-OAuth-provider and per-OTP granted scopes. Only applies to the passwordless login flow, not step-up. Empty grants no extra scopes. |
    | `aaguid_allowlist`       | No       | List of authenticator-model AAGUIDs (UUID strings) to accept. Empty list disables the filter. See [Enterprise authenticator policy](#enterprise-authenticator-policy-optional) below.                                                 |
    | `aaguid_blocklist`       | No       | List of AAGUIDs to reject outright. Same shape as the allowlist.                                                                                                                                                                      |
  </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 PUT 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": ["passkey"],
              "status": "review",
              "grant_mode": "single-use",
              "granted_for": 600,
              "steps": [
                { "order": 1, "key": "verify_passkey", "expiration_duration": 60 }
              ]
            }
          }
        ]
      }'
    ```

    A registered passkey shows up on the user as an identifier of type `passkey`, so direct-mode entries select on it via `identifier_types` like any other identifier.

    To express a passkey-or-OTP fallback, list two direct entries on the same scope — the passkey-gated one first, the OTP fallback second. 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 }]
        }
      }
    ]
    ```
  </Step>
</Steps>

## Enable passwordless login (optional)

Set `login_enabled: true` on the PasskeyConfig to opt the app into primary-factor passkey sign-in. While the flag is on, registration also requests a discoverable credential (`residentKey: required`) so the resulting passkey shows up in the browser's autofill chip.

```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": "none",
    "login_enabled": true
  }'
```

<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 and platform passkeys (iCloud Keychain, Google, Microsoft, 1Password, ...) are typically discoverable but older 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>

Authenticators that can't store a resident key — most older hardware security keys — will refuse the ceremony with `passkey_registration_failed` after the flag flips on.

## Enterprise authenticator policy (optional)

Restrict registration to specific authenticator models via the AAGUID allowlist / blocklist on the PasskeyConfig. Pairs with `attestation_preference: "direct"` or `"enterprise"` — with `"none"` most authenticators return an all-zeros AAGUID and the allowlist matches nothing.

```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 wins on collision. Filters only run at **registration** time — credentials stored before the policy was tightened remain valid for assertions, so changing the rule doesn't retroactively lock anyone out.

The FIDO Alliance publishes a [Metadata Service](https://fidoalliance.org/metadata/) that maps AAGUIDs to authenticator vendor/model names — useful when curating the allowlist.

## Subscribe to passkey lifecycle events (optional)

Three webhook events surface passkey activity for audit and user notifications:

| Event                           | When                                                                                                                                                     |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user.passkey.registered`       | A user completes the registration ceremony. The "we added a new passkey to your account — wasn't you?" notification flow keys on this event.             |
| `user.passkey.deleted`          | A user removes a credential (via the SDK or via the compromise-response `/me/revoke?target=all` flow).                                                   |
| `user.passkey.assertion_failed` | An assertion fails to verify — bad signature, sign-count regression, expired login token, etc. Useful for brute-force / cloned-authenticator monitoring. |

Subscribe via the existing webhook configuration:

```bash theme={null}
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/webhooks \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/prelude",
    "events": ["user.passkey.registered", "user.passkey.deleted", "user.passkey.assertion_failed"]
  }'
```

See the [Passkey](/auth/documentation/passkey#webhook-events) reference for the typed payload shape.

## Surface passkey state in access tokens (optional)

The custom-claims pipeline exposes a `has_passkey` template input. Map it on your app's claims configuration to let your frontend decide whether to prompt the user to enrol:

```bash theme={null}
curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "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.

## What's next?

Now that your backend is configured, integrate the frontend using the [Web Passkey SDK](/auth/documentation/frontend-sdks/web/passkey) guide. For the full reference — ceremony walk-through, security model, error catalogue, AAGUID policy details, and webhook payloads — see the [Passkey](/auth/documentation/passkey) page.
