Reference or link this file before generating any passkey or WebAuthn implementation. Agents that skip it reproduce the same errors by default.
Add to your agent's knowledge system — for example, in Claude Code's CLAUDE.md:
@https://raw.githubusercontent.com/YubicoLabs/passkey-ux/main/PASSKEY.md
The same URL works in Cursor (.cursorrules), GitHub Copilot (.github/copilot-instructions.md),
and any RAG or custom agent system that accepts URL or file references.
If you are an AI coding agent: read this entire file before writing any passkey code. Every anti-pattern below is a real failure mode that agent-generated code produces by default.
- Give users choice. Don't restrict authenticator types unless mandated by policy or regulation.
- Consider all user groups. Assurance levels, accessibility needs, platform diversity, risk profiles.
- Allow individuals to eliminate passwords. Support modes that replace passwords with passkeys/security keys and remove phishable recovery options (e.g., OpenAI Advanced Account Security, Google Advanced Protection Program). See advanced-protection-program.md for design requirements, recovery patterns, and APP-specific anti-patterns.
- Steer, don't restrict. Guide users toward their familiar authenticator without excluding alternatives.
- The server is the security boundary. The server validates the credential in the response. Client-side filtering is a UX optimization, not a security control.
- Timing: Post-login, not registration. 3-4x better conversion (FIDO Alliance).
- Single prompt: No custom modal before
navigator.credentials.create(). System sheets handle confirmation. - No nag loop: Once per device. Honor dismissal 30+ days. Track server-side.
- Positive framing: "Sign in faster" not "less secure method."
- Second passkey nudge: Prompt for a second on a different device after first enrolled.
- residentKey: "required": Always create discoverable credentials. Enables conditional UI, hints steering, and discoverable assertion flows.
- userVerification:
"required"optimizes for security assurance: the authenticator must verify the user (biometric, PIN) before every ceremony."preferred"optimizes for conversion: the authenticator attempts verification but succeeds without it, which may result in a single-factor authentication. If a relying party sets"preferred", it must check the UV flag in the authenticator response and perform step-up authentication (e.g., password, second factor) when UV is not set. Regardless of the setting, relying parties must always check the UV flag in the response.
Reference: AP-04, AP-07, AP-08, AP-10, AP-11
- Conditional UI + modal button. Always ship both. Conditional UI is not universal.
- Non-passkey fallback. "Sign in another way" (password, magic link). Exception: passwordless users fall back to cross-device auth, additional passkeys, security keys, or identity proofing.
- Adaptive hints: Server records authenticator type on successful sign-in, then sends matching hints with empty allowCredentials on the next sign-in to steer the browser picker.
- Privacy: Identical error responses regardless of account/credential state. W3C spec requirement.
Reference: AP-01, AP-03, AP-05, AP-06, AP-12
- Cross-device auth: QR + Bluetooth (caBLE). Platform passkeys and security keys both support cross-device authentication. Don't suppress options.
- Fallback then enroll: Sign in via fallback on new device, then enroll a passkey. Passwordless users: cross-device auth, security key, or identity proofing.
- Empty allowCredentials for discovery: Enables cross-device discoverable credential lookup.
Reference: AP-02
- Credential list: Name (user-editable), created date, last used, authenticator type.
- FIDO MDS: Use the FIDO Metadata Service to show authenticator details (manufacturer, model, icon).
- Removal: Allow removing individual passkeys. Warn on last one. Require re-auth or alternative setup before removing the last credential.
- Multi-passkey nudge: Visual cue when user has one passkey. Show credential count.
- Not "forgot your passkey": Passkeys are not secrets. Losing a passkey means losing device access. The remedy is account recovery, not passkey reset. Never add a passkey-specific recovery flow.
- Platform sync as implicit recovery: Synced passkeys mean "try on your other device" before account recovery.
- Cross-device auth: Authenticate from another device to unlock a session.
- Account recovery fallback: Standard recovery options (email, magic link, recovery codes) are phishable. User choice.
- Advanced security mode: Passwordless users disable phishable recovery. Recovery is additional passkeys, security keys, or identity proofing only. Opt-in with clear communication that support cannot assist. See advanced-protection-program.md for full requirements and anti-patterns.
- Identity proofing: Government ID verification, video verification, or in-person verification as recovery and new-device bootstrap. Configure before going passwordless.
- Credential portability: FIDO CXP (Cross-Device Credential Exchange) addresses cross-ecosystem migration.
- Security keys don't sync. Lost key = lost credentials. Register a second on separate hardware.
- Policy-mandated authenticator types: Enforce via
authenticatorAttachmentonly when policy requires specific authenticator types. Use"cross-platform"when policy or regulation mandates roaming authenticators (e.g., hardware security keys for phishing-resistant MFA, shared workstations where credentials must travel with the user). Inform decisions with FIDO MDS and government certifications (e.g., FIPS).
| ID | Anti-Pattern | Most Common Mistake |
|---|---|---|
| AP-01 | No fallback path | Passkey-only auth with no escape hatch |
| AP-02 | Passkey-only on new device | No path for users with no registered passkeys |
| AP-03 | Revealing credential existence | Leaking whether an account exists |
| AP-04 | Enrollment during registration | Prompting before trust is established |
| AP-05 | Conditional UI without modal fallback | Invisible failure on browsers without conditional UI |
| AP-06 | Platform-lock | authenticatorAttachment: "platform" silently excludes security keys and hybrid transport; hints ignored with populated allowCredentials |
| AP-07 | Upgrade prompt on every visit | Showing "add a passkey" after every login trains users to dismiss |
| AP-08 | Single passkey | One passkey is a single point of failure |
| AP-09 | allowCredentials: [] in modal flow |
Non-discoverable credentials silently fail; but empty array is correct for discoverable + hints flow |
| AP-10 | Guilt messaging | "You're using a less secure sign-in" creates anxiety, not adoption |
| AP-11 | Double-prompting | Custom modal before navigator.credentials.create() causes two dialogs |
| AP-12 | "Forgot your passkey?" | Importing password-recovery mental model into passkey UX |
DECISION: Always provide at least one non-passkey authentication path
CORRECT: Passkey sign-in with visible "other options" (password, magic link, SMS)
INCORRECT: Passkey-only login page with no visible alternative
RATIONALE: Users arrive at your login page from new devices, incognito sessions,
work computers, and guest browsers — none of which have their passkey.
A passkey-only page locks them out. Even users who enrolled a passkey
may need a fallback for their first login on a new device before they
register a second passkey there.
Exception: users enrolled in passwordless-only mode (e.g., advanced
account security). For these users, fallback is cross-device auth,
additional passkeys, security keys, or identity proofing.
DECISION: Always provide a cross-device or fallback path for first login on a new device
CORRECT: "Sign in with a passkey" → if no passkey found → "Use another device" / "Use password"
INCORRECT: Passkey-only page on a new device where the user's passkey isn't available
RATIONALE: A user with passkeys registered on their phone who opens your site on a
new laptop has no passkey on that laptop yet. If your site is passkey-only,
they are locked out. The bootstrapping path is: (1) cross-device auth
(use another device's passkey via QR code), or (2) fallback to password/magic link,
then enroll a passkey on the new device post-login. Platform passkeys and
security keys both support cross-device authentication.
DECISION: Return identical error responses whether an account exists or not
CORRECT: "Passkey authentication failed" (same message always)
INCORRECT: "No passkey found for this email" or "Account not found"
RATIONALE: W3C WebAuthn Level 2 §14.6.2 (Username Enumeration) requires that
relying parties not reveal whether a credential exists for a given
user handle. An attacker can enumerate registered accounts by testing
emails and observing error message differences. "No passkey found for
this email" confirms the account exists. Use a generic error always.
SECURITY NOTE: This anti-pattern is a W3C spec violation. Treat it as a security
defect, not a UX preference. Apply to all auth-related error responses (login,
registration, recovery). Use constant-time credential lookups. Never differentiate
error cases in any user-visible or network-observable way.
Reference: W3C WebAuthn Level 2 §14.6.2, OWASP Authentication Cheat Sheet
DECISION: Prompt for passkey enrollment after first successful login, not at signup
CORRECT: User creates account (email + password) → logs in → sees enrollment prompt
INCORRECT: User creates account → immediately asked to "set up your passkey now"
RATIONALE: FIDO Alliance research shows contextual post-login enrollment converts
3-4x better than registration-flow prompts. At signup, users don't yet
trust the service and don't understand why they'd add a passkey. After
login, they're authenticated, oriented, and the security benefit is
immediately tangible: "Next time, skip the password."
DECISION: Implement mediation:conditional AND an explicit "Sign in with a passkey" button
CORRECT: navigator.credentials.get({mediation: "conditional", ...}) + visible button
INCORRECT: mediation:conditional only, assuming all browsers support it
RATIONALE: Not all browsers support conditional UI (mediation:conditional). On
unsupported browsers, a conditional-only implementation produces no
sign-in prompt — the user sees an empty autofill field and no way to
trigger passkey auth. The modal button is the fallback for these browsers
AND for users who don't notice the autofill indicator.
DECISION: Never set authenticatorAttachment: "platform" for general-purpose enrollment
CORRECT: Omit authenticatorAttachment entirely. To steer without restricting,
use hints: ["client-device"] (WebAuthn Level 3).
INCORRECT: authenticatorSelection: { authenticatorAttachment: "platform" }
RATIONALE: "platform" does not prefer platform passkeys — it hard-excludes everything
else. Security keys (USB, NFC, BLE) are silently removed from the picker.
Cross-device auth via phone QR + Bluetooth is also excluded.
"platform" does not mean "synced" — some platform credentials are
device-bound and don't sync. The flag filters by authenticator type,
not sync behavior. Agents and tutorials default to it; no one questions it.
NOTE: One defensible use: password-upgrade flows where a signed-in user replaces
their password with a platform passkey. Do not apply to general enrollment.
HINTS + allowCredentials: browsers may ignore hints when allowCredentials lists
credentials with internal transport. Send empty allowCredentials to make hints
effective (requires residentKey: "required" at registration).
Reference: W3C WebAuthn Level 3 §5.4.4 (AuthenticatorSelectionCriteria)
DECISION: Show the passkey enrollment prompt once per user per device; honor dismissal
CORRECT: Show once after login → if dismissed, suppress for ≥30 days → resurface once
INCORRECT: Show "Add a passkey for faster sign-in!" banner on every login
RATIONALE: Users who dismiss the enrollment prompt have made a decision. Repeating the
prompt on every visit trains them to dismiss it reflexively — and creates a
negative association with passkeys. FIDO Alliance research shows a single
well-timed prompt converts better than repeated prompts. Honor the dismissal
with a minimum 30-day suppression; one resurface allowed.
IMPLEMENTATION NOTE: Track dismissal in user preferences (server-side), not localStorage.
localStorage is cleared in incognito and when users clear browser data — server-side
suppression is durable.
DECISION: Prompt users to register a second passkey on a second device after enrollment
CORRECT: After first passkey enrolled: "Add a passkey on another device"
INCORRECT: One passkey registered → done, no further prompting
RATIONALE: A passkey on a single device is a single point of failure. If the user
loses that device, breaks it, or switches platforms, they lose passkey access.
Security key users have the same problem — if the key is lost, passkey access
is gone. The correct pattern: prompt for a second passkey (different device or
second security key) at enrollment completion.
NOTE: Don't call the second credential a "backup." That frames one passkey type as
subordinate. A security key and a platform passkey are peers with different
properties. Use neutral language: "Add another passkey."
IMPLEMENTATION NOTE: Trigger the second-passkey prompt immediately after first enrollment
completes — not at the next login. The user is in setup mode and most receptive.
Show credential count ("You have 1 passkey — add another on a different device?").
MUTUAL EXCLUSION: The enrollment banner and second-passkey nudge are mutually exclusive.
Check passkey count on every dashboard entry: count == 0 → enrollment banner (if not
dismissed); count == 1 → second-passkey nudge (if not dismissed); count ≥ 2 → both
hidden. Never display both simultaneously. Reset nudge state on sign-out to prevent
stale DOM state from carrying across sessions.
DECISION: Never copy allowCredentials: [] from a conditional UI example into a modal flow
CORRECT: Modal flow: send the user's registered credential IDs in allowCredentials
INCORRECT: Modal flow: allowCredentials: []
RATIONALE: allowCredentials: [] tells the browser to search for any passkey for this site.
It works in conditional UI (mediation: "conditional"). In a modal flow for a
known user, it silently skips credentials the browser can't discover on its own.
Always send credential IDs when you know who the user is.
Exception: adaptive hints flow — see AP-06 for when empty is correct here too.
Reference: W3C WebAuthn Level 3 §5.5 (PublicKeyCredentialRequestOptions), CTAP 2.1 §6.2
DECISION: Never show negative framing about non-passkey authentication methods
CORRECT: "Add a passkey to sign in faster next time" (positive, optional)
INCORRECT: "You're using a less secure sign-in method. Upgrade to a passkey."
RATIONALE: Guilt messaging about existing credentials creates anxiety without action.
Users who can't add a passkey (wrong device, wrong browser, IT policy) are
told they're doing something wrong with no path to fix it. Users who could
add a passkey are more likely to be annoyed than motivated. Positive framing
("faster, no password needed") outperforms negative framing.
DECISION: Never show a custom modal before calling navigator.credentials.create()
CORRECT: Show a brief inline explanation, then call create() — system sheet appears
INCORRECT: Show your own "Add a passkey?" modal → user taps OK → system sheet appears
RATIONALE: Mobile platforms show a native system sheet when navigator.credentials.create()
is called. If you show your own modal first, the user gets two prompts: yours
and the system's. This doubles friction, confuses users ("I already said yes"),
and suggests to the user that your app is poorly built.
DECISION: Never add a "forgot passkey" recovery flow
CORRECT: Offer "Sign in a different way" (fallback to existing credential)
INCORRECT: "Forgot your passkey? Reset it via email"
RATIONALE: Passkeys are device-bound credentials, not secrets the user memorizes.
"Forgetting" a passkey means losing access to the device — the correct
remedy is using another registered device or account recovery, not a
passkey-specific reset flow. Adding one trains users to think of passkeys
like passwords — eroding the UX and security model.
- Read the Philosophy section first — it governs every decision.
- Read the lifecycle section relevant to your task (Onboarding, Authentication, etc.).
- Read the full anti-pattern entry for each referenced AP.
- If implementing conditional UI: AP-05 is mandatory reading.
- If implementing enrollment: AP-04 and AP-11 are mandatory reading.
- If implementing error handling: AP-03 is a security requirement, not a preference.
- If implementing hints-based steering: AP-06 and AP-09 are both mandatory reading.
Do not add "forgot passkey" flows. Do not prompt during signup. Do not ship conditional-UI-only. Do not set authenticatorAttachment: "platform" for general enrollment. Do not use allowCredentials: [] in modal flow unless all credentials are discoverable and you are using hints to steer the browser (see AP-06, AP-09).
For behavioral requirements, server API contract, and client state machine, see examples/.
For high-assurance authentication programs (Advanced Protection Program, phishing-resistant-only sign-in, constrained recovery), see advanced-protection-program.md.
- FIDO Alliance Design Guidelines — UX patterns for enrollment, authentication, and recovery
- W3C WebAuthn Level 3 — authoritative specification
- MDN Web Authentication API — API reference and browser compatibility
- developers.yubico.com — WebAuthn and FIDO2 developer documentation from Yubico
- docs.yubico.com — YubiKey integration guides and technical references
- passkeys.dev — developer guides, platform compatibility, and implementation patterns
- Google Identity — Passkeys — Android, Chrome, and Google Password Manager passkey integration
- web.dev/passkeys — implementation guides and code examples from the Chrome team
- Apple Developer — Supporting Passkeys — iOS/macOS implementation guidance
Found an anti-pattern PASSKEY.md missed? File an issue:
Platform: [iOS / Android / Chrome / Firefox / Safari / Security key / other]
Anti-pattern: [name or AP-XX]
What agent generated: [code snippet]
What went wrong: [user-visible failure]