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.