Skip to main content
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 page.

Prerequisites

Before you start, make sure you have:
  • A Prelude account with access to Prelude Auth
  • An Application ID (appID) — see 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.
1

Create a passkey configuration

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
  }'
FieldRequiredDescription
rp_idYesThe Relying Party identifier — the effective domain (no scheme, no port). Credentials are scoped to this RPID; changing it later breaks existing credentials.
rp_nameYesHuman-readable display name shown by authenticators during ceremonies.
allowed_originsYesList of permitted origins (scheme + host + optional port). Must be a superset of the RPID. For local development, http://localhost:<port> is accepted.
user_verificationNorequired (default), preferred, or discouraged. Use required for MFA so the ceremony proves something the user knows or is, not just possession.
attestation_preferenceNonone (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_enabledNoDefaults to false. Set to true to opt the app into primary-factor passwordless passkey login. See Enable passwordless login below.
granted_scopesNoSession 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_allowlistNoList of authenticator-model AAGUIDs (UUID strings) to accept. Empty list disables the filter. See Enterprise authenticator policy below.
aaguid_blocklistNoList of AAGUIDs to reject outright. Same shape as the allowlist.
2

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.
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:
"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 }]
    }
  }
]

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.
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
  }'
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.
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.
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 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:
EventWhen
user.passkey.registeredA 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.deletedA user removes a credential (via the SDK or via the compromise-response /me/revoke?target=all flow).
user.passkey.assertion_failedAn 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:
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 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:
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 guide. For the full reference — ceremony walk-through, security model, error catalogue, AAGUID policy details, and webhook payloads — see the Passkey page.