| Step key | Verified by | Out-of-band channel | Phishing-resistant |
|---|---|---|---|
verify_sms | Your backend | SMS | No |
verify_email | Your backend | No | |
verify_passkey | Prelude | None (WebAuthn) | Yes |
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 averify_passkey step.
Configure passkeys on the app
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.
| 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. |
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 typepasskey, 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:
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:
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 theprld: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) |
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:
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-inhas_passkey input:
Use the passkey in step-up
Once a credential is registered, callingrequestStepUp 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:
What Prelude does
- 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. - 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:writescope as part of the credential write. - 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.
- 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_unavailableso the host app can fall back without exposing implementation details. - 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. |
| 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. |
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_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. |
| 403 | insufficient_scope | The session does not hold prld:passkey:write. Run a step-up to grant the scope before retrying registration. |
| 403 | passkey_not_configured | The application has no PasskeyConfig. Configure the Relying Party first. |
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
- 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_unavailableafter previously working, investigate. - Rotate the Relying Party with care. Changing
rp_idinvalidates 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.