Skip to main content
Passkeys serve two roles on the SDK:
  • MFA step-up factor — the user already has an authenticated session and proves a passkey to acquire a sensitive scope. Driven by continueWithPasskey.
  • Primary-factor sign-in — the user signs in with just a passkey, no email/phone OTP, no password. Driven by loginWithPasskey. Requires PasskeyConfig.login_enabled server-side.
For the full conceptual reference — ceremony shape, security model, error catalogue — see the Passkey page. For the backend setup, see the Passkey integration guide.

Detect WebAuthn support

Gate the UI on the isPasskeySupported() helper. Returns false on browsers without a usable WebAuthn implementation (older Safari, headless contexts, some embedded webviews).

Register a passkey

Registration runs inside an authenticated session and consumes the prld:passkey:write scope, typically obtained via a step-up challenge just before enrolment so adding an authenticator always requires an additional ownership proof.
A user may register multiple credentials. The registration ceremony pre-populates excludeCredentials so the same authenticator can’t be registered twice — duplicate registration is the idempotent alreadyRegistered: true path. The SDK invalidates its cached session and refreshes after a successful enrolment, so the next access token reflects the consumed scope and the (optionally mapped) has_passkey claim.

Sign in with a passkey (primary factor)

loginWithPasskey opens a WebAuthn ceremony with empty allowCredentials so the browser surfaces every discoverable credential it holds for the configured RPID. The server resolves the user from the assertion’s userHandle — no identifier is sent from the client. Requires PasskeyConfig.login_enabled server-side. While the flag is on, registration also requests residentKey: required so the credential is discoverable.

Conditional UI (autofill)

Pass mediation: "conditional" to surface matching credentials in the username-field autofill chip instead of a modal authenticator picker. Pair with an AbortSignal so the SDK call cancels cleanly when the user picks a different sign-in method.

Complete a verify_passkey step-up step

When requestStepUp returns a challenge whose first step is verify_passkey, complete it with continueWithPasskey. The SDK caches the assertion options under the challenge id, so the call is parameter-light:
continueWithPasskey runs navigator.credentials.get() against the cached options, posts the assertion to /stepup/continue, and the SDK refreshes the session automatically.

Manage registered passkeys

A “Manage your passkeys” UI uses three endpoints under /me/passkeys.

List

Returns an empty array when the user has none — not a 404 — so a settings page can render without special-casing.

Rename

Renaming the label requires prld:passkey:write, the same scope as registration — drive a step-up first if the session doesn’t hold it. Pass an empty string to clear the label.

Delete

Deletion requires prld:passkey:write — removing an authenticator is a sensitive operation, so it needs the same fresh step-up as registration rather than relying on the ambient session.
The SDK invalidates the cached session and refreshes after a successful delete since removing a credential can flip the has_passkey custom claim.
A passkey is never the first thing a user sets up — registration runs inside an authenticated session and consumes the prld:passkey:write scope. So a working demo needs two things the snippets above leave out:
  • A primary login method to establish the session before any passkey exists. Here we use email OTP. The same first screen also offers loginWithPasskey, so a returning user can sign in with a passkey directly instead of email — on a fresh account that button has nothing to match yet, which is exactly why the email path is needed to bootstrap the first credential.
  • A step-up to obtain prld:passkey:write before enrolment. After login the enrollment screen has two explicit buttons: Grant scope runs requestStepUp({ scope: "prld:passkey:write" }), and Register a passkey then calls registerPasskey. To keep the demo short we configure the scope in direct mode with status: "continue" and grant_mode: "session-bound", so the grant is immediate, requires no extra OTP challenge, and stays on the session — grant once, then register as many times as you like. In production, gate it behind a real verify_email / verify_sms / verify_passkey step so adding an authenticator always requires a fresh ownership proof.
1. Register the scope
2. Configure a direct step-up for prld:passkey:writeThe step-up resolves inline (mode: "direct"), so no delegation hook is needed and jwks_url can stay empty. status: "continue" grants the scope immediately with no steps. (For a real ownership proof, switch to status: "review" and add a steps entry — see Change Password for the OTP-gated shape.)
3. Configure the PasskeyConfigPoint the Relying Party at your dev origin and set login_enabled: true so the “Sign in with a passkey” button works. allowed_origins must list your localhost (scheme + host + port) — here the Vite dev server on http://localhost:5173; with rp_id: "localhost", adjust the port to match wherever npm run dev serves.
Without login_enabled: true, loginWithPasskey returns PasskeyNotConfigured and only the email path works.4. Replace src/App.jsx
src/App.jsx
Run npm run dev, sign in with your email, then click Grant prld:passkey:write (the continue config grants it instantly) followed by Register a passkey to enroll the credential. Once you have a passkey, reload and use Sign in with a passkey to log in without email.

Error catalogue

What’s next?

  • Step-Up Authentication — the SDK surface for requestStepUp and how continueWithPasskey plugs into it.
  • Passkey reference — full ceremony walk-through, security model, AAGUID policy, webhook events.
  • Passkey integration guide — backend curl flow to configure the Relying Party identity, step-up, and (optional) passwordless / enterprise policy.