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.
Step keyVerified byOut-of-band channelPhishing-resistant
verify_smsYour backendSMSNo
verify_emailYour backendEmailNo
verify_passkeyPreludeNone (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
  • 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.
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"
  }'
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.
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 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.

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:
"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:
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 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/:
EndpointPurpose
POST /me/passkeys/register/beginIssues the PublicKeyCredentialCreationOptions and a short-lived registration_token
POST /me/passkeys/register/finishVerifies 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:
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 by mapping the built-in has_passkey input:
{
  "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.

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:
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:
import { isPasskeySupported } from "@prelude.so/js-sdk";

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

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

RuleLimit
rp_idRequired. The effective domain; no scheme, no port. Must match every origin in allowed_origins.
allowed_originsAt least one. Each must be a valid scheme + host (https://... in production; http://localhost:<port> is accepted in non-prod for local development).
user_verificationOne of required / preferred / discouraged. Defaults to required.
attestation_preferenceOne of none / indirect / direct / enterprise. Defaults to none.
Registration token TTL5 minutes. The same token cannot be reused after the ceremony completes.
Step expiration_durationSame step-up constraint: 0–86400 seconds, but keep it short (60 s is a generous WebAuthn timeout).
Credentials per userNo 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

StatusCodeCause
400bad_requestMissing or malformed registration body.
400passkey_registration_failedThe attestation could not be verified — typically a bad challenge, mismatched origin, or excluded credential.
400passkey_step_unavailableThe user has no registered credentials, the assertion failed verification (including sign-count regression — possible cloned authenticator), or the request supplied no assertion payload.
403insufficient_scopeThe session does not hold prld:passkey:write. Run a step-up to grant the scope before retrying registration.
403passkey_not_configuredThe 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

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.