Passkey UX Patterns
The passkey surfaces PasskeyBridge ships today, the exact strings they show, the error names they map, and which patterns are recommendations for your own application rather than shipped behavior.
Overview
This guide covers the passkey surfaces PasskeyBridge ships in its own dashboard and sign-in page, and marks clearly which patterns are recommendations you would build yourself. Everything described as shipped is in src/hooks/usePasskeys.ts, src/pages/PasskeyBridgeAuth.tsx, the Settings tab, and the passkey edge function behind them.
What ships today
- A Sign in with passkey button on the sign-in page, rendered only when
browserSupportsWebAuthn()is true, driving a discoverable-credential ceremony with no email typed first. - A one-screen enrollment prompt after first email verification, offering Register Passkey or Skip for now.
- An Add passkey button and a credential list in Dashboard > Settings > Passkeys, with a confirmation dialog before removal.
- Email and password plus Google, GitHub and Microsoft SSO as the alternatives, always visible.
What does not ship today
- Conditional UI (passkey autofill). No code path sets
useBrowserAutofillormediation: "conditional". - A "use a passkey from another device" link. Cross-device sign-in still works through the browser's own hybrid transport, without a dedicated control.
- Any per-tenant policy engine for authentication methods. PasskeyBridge does not enforce passkey-only access or step-up rules for your application.
The platform surfaces are bound to the RP ID passkeybridge.io. For passkeys under your own RP ID the equivalent server calls are shield-passkey-rp, described in Passkeys.
Registration flow UX
Enrollment is offered after the user is already authenticated, never as a gate on sign-up.
Post-verification prompt. When the sign-in page is reached with ?first=true or ?type=signup and the browser supports WebAuthn, it calls check_enrolled. If the user has no credential it renders a full screen headed Set up biometric sign-in, with the body Register a passkey to sign in instantly with Face ID, Touch ID, or your device PIN. and two controls: Register Passkey and Skip for now. Both paths end on the same provisioning status page, and both record a funnel event (passkey_enrolled or passkey_skipped), including when the user dismisses the browser prompt.
Settings. Dashboard > Settings > Passkeys always shows Add passkey, whether or not credentials already exist.
Sequence behind either button
| Step | What happens | What the user sees |
|---|---|---|
| 1 | registration_options | The button switches to a spinner |
| 2 | Server stores a registration challenge, five-minute TTL | No visible change |
| 3 | startRegistration({ optionsJSON }) | The platform prompt appears |
| 4 | The user approves with biometric or PIN | The prompt closes |
| 5 | verify_registration with the attestation | The spinner resumes briefly |
| 6 | The credential row is written | Toast Passkey registered! You can now use biometric login. |
Duplicate prevention. The options carry excludeCredentials with every credential id the user already has, so re-enrolling the same authenticator is refused by the browser before any prompt appears; @simplewebauthn/browser surfaces that as InvalidStateError.
Concurrency. The registeringPasskey flag disables the button for the whole sequence, and the server deletes the user's previous registration challenge before storing a new one, so a double click cannot leave two live challenges.
Authentication flow UX
Sign-in is two round trips that the user experiences as one biometric prompt.
| Step | What happens | What the user sees |
|---|---|---|
| 1 | login_options, unauthenticated | The button shows Verifying... |
| 2 | Server stores a challenge and returns challenge_handle | No visible change |
| 3 | startAuthentication({ optionsJSON }) | The browser offers the passkeys it holds for passkeybridge.io, then prompts |
| 4 | The user approves | The prompt closes |
| 5 | login_verify with the assertion and the handle | Brief pause |
| 6 | supabase.auth.verifyOtp({ token_hash, type: "magiclink" }) | Toast Signed in with passkey, then the dashboard |
No email field. login_options returns an empty allowCredentials, so the ceremony is discoverable: the browser picks from the passkeys it stores for the RP ID and the user never types an identifier. That is also why the credential lookup on the server is global rather than tenant-scoped, with the credential's tenant checked before a session is minted.
Multiple credentials. When a user holds several passkeys for the RP, the browser shows its own picker before the biometric prompt. The server does not influence that list on this path, because it sends no allowCredentials. On the step-up path (authentication_options) it does send the user's credential ids with their stored transports, which lets the browser show transport-appropriate entries.
Counter behavior. The verifier rejects an assertion whose signature counter has not advanced. On the sign-in path that produces Passkey verification failed with status 401; the anomaly is not merely logged, the sign-in fails.
Conditional UI (autofill-assisted)
Not implemented today. No PasskeyBridge code path requests conditional mediation: there is no useBrowserAutofill, no mediation: "conditional", and no autocomplete="username webauthn" input on the sign-in page. Passkey sign-in is an explicit button press. This section is a recommendation for your own application rather than a description of shipped behavior.
The pattern. Conditional UI lets the browser offer passkeys inside the username field's autofill dropdown, alongside saved passwords, so the user never presses a passkey button at all.
- Render the identifier input with
autocomplete="username webauthn". - On page load, request authentication options from your own backend and call
startAuthenticationwithuseBrowserAutofill: true. - The call stays pending; no modal appears. When the user focuses the field, the browser lists matching passkeys.
- Selecting one runs the biometric prompt inline and resolves the pending call with an assertion, which you verify server-side.
Fail silently. If the browser has no support, or the user has no credential for your RP, the promise never resolves or rejects quietly. The password form must stay usable and no error state may be shown. This is the rule that makes the pattern safe to add.
If you build it against PasskeyBridge's own RP, note that login_options requires an allowlisted Origin and is reachable only from PasskeyBridge origins, so conditional UI for your users belongs on your own domain against the hosted relying party shield-passkey-rp, whose auth_options action returns the same option shape plus a challenge_handle.
Cross-device authentication
Cross-device sign-in lets a user authenticate on a laptop with a passkey held on a phone, over the FIDO2 hybrid transport (a QR code plus a Bluetooth proximity check).
What the platform does. Nothing special. The server side is identical to same-device authentication: it issues a challenge and verifies the assertion. The browser and the two operating systems own the QR rendering, the Bluetooth exchange and the transport. A credential enrolled from a phone typically stores "hybrid" in its transports array, which appears in list output and in the step-up allowCredentials.
Synced credentials usually make it unnecessary. A passkey synced through iCloud Keychain or Google Password Manager is already present on the user's other devices, so the QR flow is only needed when the credential is not synced to the device in front of the user.
No dedicated control ships today. The sign-in page offers Sign in with passkey and nothing else; there is no "use a passkey from another device" link. Pressing the button on a device with no local passkey still reaches the browser's own cross-device path, because the ceremony is discoverable. If you build your own sign-in page, adding an explicit link is worthwhile, since it sets the expectation that a QR code is about to appear.
Fallback strategies
Passkeys are always an addition to the existing sign-in methods on the PasskeyBridge dashboard.
| Method | Availability |
|---|---|
| Passkey | Shown when the browser supports WebAuthn and the user has enrolled one |
| Email and password | Always shown |
| Google, GitHub, Microsoft SSO | Always shown as buttons |
| Password reset by email | From the sign-in page, independent of passkey state |
Decisions the product makes
- Enrollment is never required. The prompt after email verification is skippable, and skipping records
passkey_skippedand continues to provisioning. - Deleting the last passkey is allowed.
deletePasskeyenforces no minimum, and the account falls back to the methods above. - The passkey button disappears rather than erroring when
browserSupportsWebAuthn()is false.
Passkey-only policies are yours to enforce. PasskeyBridge does not gate features on authentication method, and there is no tenant setting for it. If you want passkey-only access or step-up before a sensitive action in your own application, hold that policy in your application and use the step-up pair (authentication_options and verify_authentication) or the hosted relying party's auth_options and auth_verify as the check.
Losing every device. A user whose credentials are all single-device and all lost signs in with email and password, or resets the password by email, and then enrolls new passkeys from Settings.
Error handling and user messaging
WebAuthn error names are not user-facing text, and the shipped translation is thinner than it looks.
Enrollment from Settings (`usePasskeys.registerPasskey`). Exactly one error name is mapped. Everything else surfaces the raw error message, truncated to 120 characters, in a red toast.
| Condition | Toast |
|---|---|
NotAllowedError (prompt dismissed or timed out) | Passkey registration was canceled. |
| Options request failed | Failed to get registration options |
Server answered without verified | Verification failed |
Any other error, for example InvalidStateError or SecurityError | The raw message, truncated to 120 characters |
| Success | Passkey registered! You can now use biometric login. |
Deletion. Passkey removed on success, Failed to remove passkey when the call errors. The list refresh that follows is silent on failure.
Sign-in page. NotAllowedError returns quietly with no toast at all, because a dismissed prompt is not an error worth interrupting for. Any other failure reports auth.passkey_login_failed with the error name and shows the server's message, or Passkey sign-in failed.
Enrollment prompt after signup. NotAllowedError shows the neutral toast Passkey enrollment skipped. and continues to provisioning. Other failures show Enrollment failed—you can add a passkey later in Settings. and also continue, so a failed enrollment never traps a new account.
Listing fails silently. fetchPasskeys swallows its error. If the edge function is unreachable the Settings tab renders without passkey rows and with no error state, which keeps one unavailable service from breaking the tab.
Recommendation for your own surfaces. Pair every failure with a next action, and map at least InvalidStateError (already enrolled) and SecurityError (RP ID or origin mismatch) to plain sentences rather than passing the raw message through.
Device type and sync status indicators
list returns device_type, backed_up, transports and last_used_at for each credential, which is enough to tell the user what they are looking at.
| Fields | Meaning | Suggested treatment |
|---|---|---|
device_type is multiDevice and backed_up is true | Synced through a platform provider | Cloud icon, "Synced across your devices" |
device_type is multiDevice and backed_up is false | Sync-capable but not yet backed up | Cloud icon with a caution, "Not yet backed up" |
device_type is singleDevice | Hardware key or a device-bound credential | Key icon, "This device only" |
Transports. internal is a built-in authenticator, hybrid is the cross-device QR flow, usb, nfc and ble are security keys. The array comes from the attestation response at enrollment and is stored verbatim.
What the dashboard shows today. Each row in Dashboard > Settings > Passkeys prints the first 16 characters of the credential id and the registration date. The row's title is chosen by comparing device_type with the string platform, which no WebAuthn response ever produces: the stored values are singleDevice and multiDevice. Every row therefore reads Security key, including synced platform passkeys. Do not copy that comparison; branch on singleDevice against multiDevice instead.
Last used. last_used_at is set on every verified assertion, on both the sign-in and step-up paths, and is null until the credential is first used. A relative timestamp plus a "Never used" case is enough for a user to spot a credential that is safe to remove.
Ordering. list returns newest first by created_at. Sorting by last_used_at in the client surfaces the credentials a user actually recognizes.
Security key considerations
Hardware security keys behave differently from platform authenticators and are worth their own copy.
| Dimension | Platform passkey | Hardware security key |
|---|---|---|
| Storage | Device secure enclave, often synced | On the key itself |
| User verification | Face, fingerprint or device PIN | Touch, plus a PIN on some models |
Reported device_type | Usually multiDevice | singleDevice |
| Counter | Often reports zero on synced credentials | Advances on every assertion |
| Recovery if lost | Restore from the provider's backup | None, enroll a replacement |
Recommendations
- Prompt security-key users to enroll two keys, a primary and a spare, so losing one is not a lockout.
- Warn before removing a
singleDevicecredential that it cannot be recovered. - Use the
transportsarray to pick iconography: a key that reportsusb,nfcorblerather thaninternalis physical hardware.
Authenticator model policies are not available on the dashboard path. The passkey function requests no attestation and never writes the aaguid column, so AAGUID allowlists cannot be enforced or even reported for dashboard passkeys, on any plan. The hosted relying party shield-passkey-rp does store aaguid at enrollment and import and returns it from its list action, so a policy built on authenticator models belongs there, enforced in your own backend against that value.
Implementation checklist
Enrollment
- Offer enrollment after authentication, never as a gate on sign-up.
- Check
browserSupportsWebAuthn()before rendering any passkey control, and render nothing when it is false. - Disable the button for the whole ceremony, the way
registeringPasskeydoes. - Send
excludeCredentialsso re-enrolling the same authenticator is refused before the prompt. - Treat
NotAllowedErroras a dismissal with a neutral message, not a failure.
Sign-in
- Keep email and password, or another provider, visible at all times.
- Use a discoverable ceremony (empty
allowCredentials) so the user types nothing. - Return the
challenge_handlewith the assertion; the sign-in challenge cannot be found without it. - Exchange the one-time
token_hashimmediately and never log or forward it. - If you add conditional UI, make every failure silent.
Credential management
- Show
device_type,backed_upandlast_used_atper credential, branching onsingleDeviceagainstmultiDevice. - Confirm before deletion and say that it cannot be undone.
- Warn when the credential being removed is the last one.
- Expect
{ "deleted": true }even when nothing matched, and refresh the list rather than trusting the response.
Server expectations
- Challenges expire in five minutes and verify once.
- The
Originheader must be on the relying party's allowlist or the request is refused with 403. - A counter that has not advanced fails the assertion.