Feature-Detecting Passkey and Conditional Mediation Support

Reliable passkey UX rests on three probes that answer three different questions, and conflating them is the root of most “the button shows but nothing works” bugs. This page defines each probe, the safe way to call it (they can throw or be absent on older engines), how to cache the results without staling them, and why user-agent sniffing is never a substitute. It sits under Progressive Enhancement and Passkey Fallback Flows.


The Three Probes

Probe Question it answers Absent/false means
typeof window.PublicKeyCredential !== 'undefined' Is the WebAuthn API present at all? Fall back to password entirely
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() Is a built-in biometric/PIN authenticator present? Roaming keys / hybrid still work
PublicKeyCredential.isConditionalMediationAvailable() Can passkeys surface in autofill? Use a modal button instead

The methods are static on PublicKeyCredential, return promises, and may be missing entirely on engines that shipped an early WebAuthn build — so every call must guard on typeof … === 'function' and catch rejections. There is deliberately no probe for “does this user have a passkey”; the WebAuthn spec withholds credential enumeration for privacy.


Root Cause Analysis

1. Calling a probe that does not exist. PublicKeyCredential.isConditionalMediationAvailable() throws TypeError on engines where the method was never added. Guard with typeof.

2. A probe rejects instead of resolving. Some engines (and private-browsing modes) reject the platform-authenticator probe. An uncaught rejection breaks enhancement; always .catch(() => false).

3. Treating the three as one boolean. Hiding the passkey button when isUserVerifyingPlatformAuthenticatorAvailable() is false wrongly denies roaming-key and hybrid users.

4. Caching results for too long. Users enrol a fingerprint or plug in a security key mid-session; a probe cached at page load can go stale. Cache per page load, not across sessions.


Step-by-Step Resolution

Step 1 — A safe, parallel detector

export interface PasskeyCapabilities {
  api: boolean;
  platformAuthenticator: boolean;
  conditionalMediation: boolean;
}

export async function detectPasskeyCapabilities(): Promise<PasskeyCapabilities> {
  const api = typeof window.PublicKeyCredential !== 'undefined';
  if (!api) return { api: false, platformAuthenticator: false, conditionalMediation: false };

  const pkc = window.PublicKeyCredential;
  const [platformAuthenticator, conditionalMediation] = await Promise.all([
    typeof pkc.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
      ? pkc.isUserVerifyingPlatformAuthenticatorAvailable().catch(() => false)
      : Promise.resolve(false),
    typeof pkc.isConditionalMediationAvailable === 'function'
      ? pkc.isConditionalMediationAvailable().catch(() => false)
      : Promise.resolve(false),
  ]);

  return { api, platformAuthenticator, conditionalMediation };
}

Step 2 — Cache for the page lifetime only

let cached: Promise<PasskeyCapabilities> | null = null;
export function getCapabilities(): Promise<PasskeyCapabilities> {
  return (cached ??= detectPasskeyCapabilities());
}

Step 3 — Branch UI on each capability independently

const caps = await getCapabilities();
if (caps.api) revealPasskeyButton();                 // roaming + hybrid included
if (caps.conditionalMediation) startAutofill();      // non-blocking
setPasskeyCopy(caps.platformAuthenticator ? 'Use Face ID / Touch ID' : 'Use a security key or your phone');

Verification and Testing

// Unit-testable pure branch given a capabilities object:
function decidePath(c: PasskeyCapabilities): 'autofill' | 'modal' | 'password' {
  if (!c.api) return 'password';
  return c.conditionalMediation ? 'autofill' : 'modal';
}
// expect(decidePath({api:true, platformAuthenticator:false, conditionalMediation:true})).toBe('autofill')

Exercise the real probes across engines with a synthetic monitor, and in DevTools toggle the virtual authenticator’s “user verification” and “resident key” to flip the probe outputs. Verify that disabling JavaScript still yields a working password form (the enhancement never runs).


Pitfalls

1. User-agent sniffing. Version strings lie (spoofing, embedded webviews). Only the runtime probes are authoritative.

2. Awaiting probes before first paint. Render the form first; enhance after getCapabilities() resolves.

3. Persisting probe results across sessions. Capability changes when a user enrols biometrics or attaches a key. Recompute per load.


Related