Skip to main content
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. 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
  • Your Management API key for backend calls
  • Step-up enabled on the application — see 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

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

1

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.
Enterprise example — accept only two authenticator models and explicitly reject unattested credentials:
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.
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.
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.

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:
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:
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 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/: 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:
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 by mapping the built-in has_passkey input:
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: 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.

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

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.
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.
The endpoints sit under /v1/session/login/passkey/: 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". 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

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

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

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”.
  • 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?

Step-Up Authentication

Full step-up overview, including custom scopes and the delegation hook.

Custom Steps

Add client-owned steps (KYC, biometric, …) to your challenges.

Web SDK Step-Up Guide

Full code path for the JS SDK, including the passkey methods.