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.

Last reviewed September 16, 2026Fresh

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 useBrowserAutofill or mediation: "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

StepWhat happensWhat the user sees
1registration_optionsThe button switches to a spinner
2Server stores a registration challenge, five-minute TTLNo visible change
3startRegistration({ optionsJSON })The platform prompt appears
4The user approves with biometric or PINThe prompt closes
5verify_registration with the attestationThe spinner resumes briefly
6The credential row is writtenToast 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.

StepWhat happensWhat the user sees
1login_options, unauthenticatedThe button shows Verifying...
2Server stores a challenge and returns challenge_handleNo visible change
3startAuthentication({ optionsJSON })The browser offers the passkeys it holds for passkeybridge.io, then prompts
4The user approvesThe prompt closes
5login_verify with the assertion and the handleBrief pause
6supabase.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.

  1. Render the identifier input with autocomplete="username webauthn".
  2. On page load, request authentication options from your own backend and call startAuthentication with useBrowserAutofill: true.
  3. The call stays pending; no modal appears. When the user focuses the field, the browser lists matching passkeys.
  4. 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.

MethodAvailability
PasskeyShown when the browser supports WebAuthn and the user has enrolled one
Email and passwordAlways shown
Google, GitHub, Microsoft SSOAlways shown as buttons
Password reset by emailFrom 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_skipped and continues to provisioning.
  • Deleting the last passkey is allowed. deletePasskey enforces 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.

ConditionToast
NotAllowedError (prompt dismissed or timed out)Passkey registration was canceled.
Options request failedFailed to get registration options
Server answered without verifiedVerification failed
Any other error, for example InvalidStateError or SecurityErrorThe raw message, truncated to 120 characters
SuccessPasskey 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.

FieldsMeaningSuggested treatment
device_type is multiDevice and backed_up is trueSynced through a platform providerCloud icon, "Synced across your devices"
device_type is multiDevice and backed_up is falseSync-capable but not yet backed upCloud icon with a caution, "Not yet backed up"
device_type is singleDeviceHardware key or a device-bound credentialKey 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.

DimensionPlatform passkeyHardware security key
StorageDevice secure enclave, often syncedOn the key itself
User verificationFace, fingerprint or device PINTouch, plus a PIN on some models
Reported device_typeUsually multiDevicesingleDevice
CounterOften reports zero on synced credentialsAdvances on every assertion
Recovery if lostRestore from the provider's backupNone, 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 singleDevice credential that it cannot be recovered.
  • Use the transports array to pick iconography: a key that reports usb, nfc or ble rather than internal is 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 registeringPasskey does.
  • Send excludeCredentials so re-enrolling the same authenticator is refused before the prompt.
  • Treat NotAllowedError as 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_handle with the assertion; the sign-in challenge cannot be found without it.
  • Exchange the one-time token_hash immediately and never log or forward it.
  • If you add conditional UI, make every failure silent.

Credential management

  • Show device_type, backed_up and last_used_at per credential, branching on singleDevice against multiDevice.
  • 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 Origin header must be on the relying party's allowlist or the request is refused with 403.
  • A counter that has not advanced fails the assertion.

Related from the blog