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.PublicKeyCredentialexists → 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.
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
- Frontend UX and Conditional Mediation — the parent pillar and its cross-platform support matrix
- Feature-Detecting Passkey and Conditional Mediation Support — the exact probe implementation and caching strategy
- Password-to-Passkey Fallback UI Patterns — the upgrade-prompt and dual-path UX
- Conditional Mediation and Passkey Autofill UI — the autofill layer this enhancement builds on
- Debugging and Observability for Client-Side WebAuthn — diagnosing the
InvalidStateErrorandNotAllowedErrorfallback triggers