Progressive Enhancement and Passkey Fallback Flows

A passkey sign-in screen must work when JavaScript fails, when the browser is five years old, and when the user is on a shared kiosk with no platform authenticator. Progressive enhancement is the discipline that guarantees this: ship a functional password form first, then layer passkey affordances on top only where runtime feature detection confirms support, and always keep an escape hatch. This page defines the enhancement order, the coordination between conditional autofill and an explicit passkey button, and the fallback flows that prevent a user from ever hitting a dead end. It sits under Frontend UX and Conditional Mediation; pair it with the conditional mediation autofill mechanics it builds upon.


Concept Definition and Spec Grounding

Progressive enhancement inverts the usual “build the passkey UI, then add fallbacks” instinct. The base layer is a plain HTML form that POSTs credentials to a server endpoint — no WebAuthn, no JavaScript required. Enhancement then adds capability in strict order, each layer gated on a specific probe defined in WebAuthn Level 2 §5.1.6–§5.1.7:

  • window.PublicKeyCredential exists → the API is present at all.
  • PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() → a built-in biometric/PIN authenticator exists (Touch ID, Windows Hello).
  • PublicKeyCredential.isConditionalMediationAvailable() → autofill can surface passkeys.

These three are independent. A desktop Chrome without a platform authenticator still supports roaming keys and hybrid transport; a browser with the API but no conditional mediation can still run a modal ceremony. Treating them as a single boolean (“supports passkeys”) is the root of most fallback bugs. The detailed probe implementation lives in feature-detecting passkey support.


Architecture and Data Flow

The flowchart shows the enhancement decision tree from initial render to a signed-in session, with every branch terminating in a usable path — never a dead end.

Progressive enhancement decision tree Decision tree starting at a server-rendered password form, branching on API presence, platform authenticator availability, and conditional mediation, with every branch ending in a working sign-in path. Server-rendered password form PublicKeyCredential? no Password only Conditional mediation? yes Autofill + button no Modal passkey button Authenticated session

Implementation Guide

Step 1 — Ship a form that works without JavaScript

The base layer submits over a standard POST. WebAuthn is layered on afterward, so a script error or an ancient browser still authenticates.

<form id="signin" method="post" action="/login">
  <input name="username" autocomplete="username webauthn" required autofocus />
  <input name="password" type="password" autocomplete="current-password" required />
  <button type="submit">Sign in</button>
  <button type="button" id="passkey-btn" hidden>Sign in with a passkey</button>
</form>

Step 2 — Detect capabilities in parallel

Run the three probes concurrently and cache the result; do not block first paint.

interface Capabilities {
  api: boolean;
  platformAuthenticator: boolean;
  conditionalMediation: boolean;
}

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

  const [platformAuthenticator, conditionalMediation] = await Promise.all([
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.().catch(() => false) ?? false,
    PublicKeyCredential.isConditionalMediationAvailable?.().catch(() => false) ?? false,
  ]);
  return { api, platformAuthenticator, conditionalMediation };
}

Step 3 — Enhance based on capability

Reveal the modal button whenever the API exists; start conditional autofill only when it is supported. The two coordinate through one AbortController — the modal handler aborts the pending conditional call first, avoiding the InvalidStateError covered in the client-side debugging cluster.

export async function enhanceSignin() {
  const caps = await detectCapabilities();
  if (!caps.api) return; // password-only path stands as-is

  const btn = document.getElementById('passkey-btn')!;
  btn.hidden = false;

  if (caps.conditionalMediation) startAutofill(); // non-blocking, see autofill cluster

  btn.addEventListener('click', async () => {
    ceremony?.abort();                 // cancel pending conditional call
    ceremony = new AbortController();
    await runModalCeremony(ceremony.signal);
  });
}

Step 4 — Provide a fallback that never strands the user

If a local ceremony fails (NotAllowedError, ConstraintError, or no platform authenticator), offer cross-device sign-in via hybrid transport and keep the password field reachable. Never remove the password path until a user has a second registered passkey.

async function runModalCeremony(signal: AbortSignal) {
  try {
    const options = await getAssertionOptions();
    const credential = await navigator.credentials.get({
      publicKey: decodeRequestOptions(options),
      signal,
    });
    await verifyAssertion(credential as PublicKeyCredential);
  } catch (err) {
    const name = (err as DOMException).name;
    if (name === 'AbortError') return;
    // Soft-fail: reveal password + "use another device" without a hard error page
    showFallback({ reason: name });
  }
}

Validation Checklist


Error Reference Table

Error / Condition HTTP Status Trigger Diagnostic
Passkey button never appears PublicKeyCredential undefined, or JS failed to load Check console for script errors; confirm HTTPS
Autofill absent but button works isConditionalMediationAvailable() false on this engine Consult the support matrix; modal path is expected here
InvalidStateError on button click Conditional call still pending Abort the prior ceremony in the click handler
ConstraintError userVerification:'required' with no UV authenticator Offer roaming key / hybrid / password
NotAllowedError Prompt dismissed or timeout Soft fallback; re-enable button and reveal password
Hard error page shown to user 500 Rejection mapped to a fatal state Ensure all get() rejections are caught and downgraded

Platform and Library Notes

@simplewebauthn/browser

browserSupportsWebAuthn() and browserSupportsWebAuthnAutofill() wrap the probes; startAuthentication({ useBrowserAutofill: true }) handles the conditional path. Reveal the modal button on browserSupportsWebAuthn().

Server-side rendering frameworks

With Next.js/Remix/Nuxt, render the password form on the server and run enhanceSignin() in a client-only effect. Never gate the form itself behind hydration.

iOS / Safari

Safari does not expose isUserVerifyingPlatformAuthenticatorAvailable() as reliably as Chromium in some private-browsing contexts; treat a rejected probe as “unsupported” and fall back rather than throwing.

Windows Hello

Desktop Chrome/Edge report a platform authenticator when Windows Hello is configured. If the user has hardware but no enrolled biometric, the ceremony may still prompt for a PIN — do not assume “platform authenticator available” means “biometric”.


Pitfalls and Security Hardening

1. Single “supports passkeys” boolean Root cause: collapsing three independent probes. Mitigation: branch on each capability separately.

2. Removing the password path too early Root cause: hiding password entry after first passkey enrolment. Mitigation: keep it until a second passkey (or recovery method) exists — see building a passkey account recovery flow.

3. Layout shift when enhancing Root cause: injecting the passkey button after paint. Mitigation: render it hidden and reserve space; only toggle hidden.

4. Hard-failing on NotAllowedError Root cause: mapping rejection to an error page. Mitigation: soft fallback that re-enables the form.

5. Enhancing before hydration completes Root cause: assuming DOM nodes exist. Mitigation: run enhancement in a load/idle callback after the form is present.


Related