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