Fix: WebAuthn “AbortError: The user operation was aborted” in Passkey Conditional UI
In modern Web3 authentication and Account Abstraction workflows—where decentralized applications use passkeys (WebAuthn) for gasless onboarding and biometric session signing—Conditional UI (Autofill) offers the gold standard user experience. When configured properly, users see biometric passkey suggestions directly inside browser input fields.
However, developers integrating embedded signers (such as Privy, Turnkey, or ZeroDev) frequently report a frustrating, intermittent exception in production:
DOMException: The user operation was aborted.
name: "AbortError"
code: 20
at navigator.credentials.get (auth.ts:184)
at handlePasskeySignIn (SignInModal.tsx:92)
In many cases, this error not only aborts the current transaction or login request, but on iOS Safari and macOS Chrome, it triggers an internal rate-limit lockout where the browser refuses to display the system biometric prompt (Touch ID / Face ID) until the user hard-refreshes the webpage.
1. W3C WebAuthn Specification: The Single-Active-Request Rule
Under Section 5.1 of the W3C Web Authentication Level 2 & Level 3 Specifications, a user agent (browser) is prohibited from executing concurrent public key credential ceremonies:
“If another credential creation or retrieval request is pending within the current browsing context or across contexts that share an authenticator session, the user agent MUST either abort the pending request or reject the new request with an
AbortErrororNotAllowedError.”
When implementing Passkey Conditional UI, developers mount the credential listener on page load:
// Background conditional request mounted in useEffect()
navigator.credentials.get({
publicKey: getPublicKeyOptions,
mediation: 'conditional', // Waits silently for user to tap an input autofill prompt
signal: abortController.signal,
});
The bug manifests when the user bypasses autofill and explicitly clicks a “Connect with Passkey” button. The click handler initiates a standard modal ceremony:
// User-initiated modal prompt
await navigator.credentials.get({
publicKey: getPublicKeyOptions,
// mediation defaults to 'optional'
});
Because the background conditional request was still listening for input, the browser encounters two conflicting requests on the same origin. Chrome, Safari, and Firefox handle this by abruptly killing one or both ceremonies with AbortError.
2. Platform-Specific Edge Cases
Edge Case 1: WebKit Input Field Blur / Virtual DOM Re-renders
On Safari (iOS 17+ and macOS Sonoma/Sequoia), WebKit ties conditional mediation directly to the DOM state of <input autocomplete="webauthn">.
- If your UI dynamically switches tabs or re-renders the input element (common in React
useStatetriggers), WebKit detects that the anchor input has detached. - WebKit immediately aborts the credentials promise with error code
20.
Edge Case 2: Unchecked AbortController Lifecycle
If developers attempt to cancel the background listener by calling abortController.abort(), the pending navigator.credentials.get() promise rejects with AbortError. If the code lacks a catch block that filters out deliberate aborts, the unhandled rejection bubbles up to global error telemetry (Sentry, Datadog) as a critical authentication crash.
Edge Case 3: Resident Key Enumeration Delay on Android / Windows Hello
When resident credentials (discoverable keys) exceed internal secure enclave memory, Android Credential Manager and Windows Hello TPM require up to 800ms to enumerate keys. If an impatient user taps “Cancel” or double-clicks the sign-in button, the hardware aborts with AbortError and leaves the TPM thread stalled.
(For hardware TPM resident key management, see our deep-dive on WebAuthn Resident Key Discoverable Credential Overflow Fix).
3. The Robust Passkey Request Manager Pattern
To eliminate AbortError and browser prompt freezing, applications must serialize all WebAuthn calls through a Singleton Request Manager with active abort orchestration:
// auth/PasskeyManager.ts
export class PasskeyManager {
private static instance: PasskeyManager;
private currentAbortController: AbortController | null = null;
private isRequestPending: boolean = false;
private constructor() {}
public static getInstance(): PasskeyManager {
if (!PasskeyManager.instance) {
PasskeyManager.instance = new PasskeyManager();
}
return PasskeyManager.instance;
}
/**
* Safely aborts any existing WebAuthn operation before proceeding.
*/
public async cancelPendingRequest(reason = 'New ceremony requested'): Promise<void> {
if (this.currentAbortController && this.isRequestPending) {
this.currentAbortController.abort(reason);
this.currentAbortController = null;
// Yield to the event loop so the browser engine can finalize teardown
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* Initiates Conditional UI autofill safely.
*/
public async startConditionalAutofill(
options: CredentialRequestOptions,
onSuccess: (cred: Credential | null) => void
): Promise<void> {
// 1. Verify browser support for conditional mediation
if (
typeof window.PublicKeyCredential === 'undefined' ||
!window.PublicKeyCredential.isConditionalMediationAvailable
) {
return;
}
const isAvailable = await window.PublicKeyCredential.isConditionalMediationAvailable();
if (!isAvailable) return;
await this.cancelPendingRequest('Starting conditional autofill');
const controller = new AbortController();
this.currentAbortController = controller;
this.isRequestPending = true;
try {
const credential = await navigator.credentials.get({
...options,
mediation: 'conditional',
signal: controller.signal,
});
if (credential) {
onSuccess(credential);
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Expected behavior when cleanly cancelled by user action or modal switch
return;
}
console.warn('Conditional autofill error:', err);
} finally {
if (this.currentAbortController === controller) {
this.isRequestPending = false;
this.currentAbortController = null;
}
}
}
/**
* Initiates explicit modal passkey ceremony (e.g., button click).
*/
public async requestModalPasskey(
options: CredentialRequestOptions
): Promise<Credential | null> {
// 1. Abort any existing conditional autofill first!
await this.cancelPendingRequest('User triggered modal authentication');
const controller = new AbortController();
this.currentAbortController = controller;
this.isRequestPending = true;
try {
const credential = await navigator.credentials.get({
...options,
mediation: 'optional',
signal: controller.signal,
});
return credential;
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Passkey ceremony cancelled by user.');
}
throw err;
} finally {
this.isRequestPending = false;
this.currentAbortController = null;
}
}
}
4. Frontend Integration Checklist
1. Correct Input Attributes for Autofill
Ensure your HTML input tag specifies the exact autocomplete properties:
<input
type="text"
id="username"
name="username"
autocomplete="username webauthn"
placeholder="Enter wallet address or ENS"
/>
Note: The token webauthn MUST be included in the autocomplete attribute for WebKit and Blink to activate conditional mediation.
2. Component Teardown Cleanup
In React or Vue components, always cancel the abort controller in the unmount lifecycle:
useEffect(() => {
const manager = PasskeyManager.getInstance();
manager.startConditionalAutofill(requestOptions, (credential) => {
handleAuthentication(credential);
});
return () => {
manager.cancelPendingRequest('Component unmounted');
};
}, []);
5. Verification Matrix Across Modern Browsers
| Environment | Supported Mediation | Concurrency Behavior | Fix Verification |
|---|---|---|---|
| Safari (iOS 17+) | conditional, optional | Throws AbortError immediately if input unmounts or modal overlaps. | PasskeyManager.cancelPendingRequest() prevents prompt freezing. |
| Chrome / Brave (Desktop) | conditional, optional | Queues requests or aborts earlier listener with error code 20. | Clean AbortController filtering eliminates unhandled promise rejections. |
| Firefox (Desktop 122+) | optional (Full conditional in preview) | Rejects concurrent get() with NotAllowedError. | Pre-check validation via isConditionalMediationAvailable() skips unsupported browsers. |