- 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. RequiresPasskeyConfig.login_enabledserver-side.
Detect WebAuthn support
Gate the UI on theisPasskeySupported() 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 theprld: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)
Passmediation: "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
WhenrequestStepUp 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
Rename
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 requiresprld: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.
has_passkey custom claim.
Try it
Try it
A passkey is never the first thing a user sets up — registration runs inside an authenticated session and consumes the 2. Configure a direct step-up for 3. Configure the PasskeyConfigPoint the Relying Party at your dev origin and set Without Run
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:writebefore enrolment. After login the enrollment screen has two explicit buttons: Grant scope runsrequestStepUp({ scope: "prld:passkey:write" }), and Register a passkey then callsregisterPasskey. To keep the demo short we configure the scope indirectmode withstatus: "continue"andgrant_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 realverify_email/verify_sms/verify_passkeystep so adding an authenticator always requires a fresh ownership proof.
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.)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.login_enabled: true, loginWithPasskey returns PasskeyNotConfigured and only the email path works.4. Replace src/App.jsxsrc/App.jsx
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
requestStepUpand howcontinueWithPasskeyplugs 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.