Fix: Android Credential Manager WebAuthn “NotAllowedError” in Web3 Passkeys
With the adoption of ERC-4337 Smart Accounts and embedded passkey infrastructure (such as Privy, Turnkey, Coinbase Smart Wallet, or ZeroDev), decentralized applications rely heavily on WebAuthn to turn smartphones into non-custodial hardware signers.
On modern Android devices running Android 14 or Android 15, passkeys are orchestrated through the unified Google Credential Manager API. However, developers and mobile users frequently hit a roadblock during wallet creation or signature authorization:
DOMException: The operation either timed out or was not allowed by the user.
name: "NotAllowedError"
code: 0
at navigator.credentials.get (webauthn-client.ts:142)
at handleSignUserOperation (SmartAccountSigner.tsx:88)
The issue is especially acute when dApps are accessed via Telegram Mini Apps, in-app WebViews (such as Discord or Twitter), or React Native / Flutter wrappers.
1. Root Cause Architecture: How Credential Manager Validates Requests
Under Android 14+, when a Web3 webpage invokes navigator.credentials.create() or navigator.credentials.get(), Chrome does not talk directly to the Linux kernel or hardware secure element. Instead, it delegates the ceremony to Google Play Services Credential Manager:
$$\text{Web Page} \xrightarrow{\text{WebAuthn}} \text{Blink / Chrome} \xrightarrow{\text{IPC}} \text{Credential Manager} \xrightarrow{\text{Biometrics}} \text{TEE / StrongBox Keymaster}$$
The subsystem strictly enforces three validation gates. If any gate fails, Credential Manager aborts execution and dispatches NotAllowedError:
Gate 1: Digital Asset Links (assetlinks.json)
If the ceremony originates from a native Android app embedding a WebView (e.g., a mobile crypto wallet with a built-in browser), Android checks whether the app has established cryptographic ownership over the web domain (rpId). Without an active assetlinks.json verification, the OS considers the WebView an untrusted phishing vector and terminates the ceremony.
Gate 2: Biometric Enclave Lockout Counter
If a user fails fingerprint or facial recognition 5 consecutive times across any application, Android’s KeyguardManager trips an enclave lockout. Subsequent WebAuthn requests are denied automatically without prompting the user, returning NotAllowedError.
Gate 3: WebAuthn Challenge Expiration & Window Focus
The Android system enforces an internal 60-second challenge timeout. If an app performs heavy asynchronous cryptographic work (such as generating zero-knowledge proofs or fetching bundler pre-verification gas limits) before prompting the user, the server challenge expires, leading to an immediate timeout abort.
(For related browser-level cancellation anomalies, see our companion article on WebAuthn AbortError in Passkey Conditional UI).
2. Step-by-Step Resolution Protocols
Protocol 1: Fix Android Hybrid & WebView Environments
If you are developing a mobile app using React Native, Flutter, or native Kotlin/Java that displays your Web3 dApp:
- Do not use raw
android.webkit.WebViewfor passkey auth. Raw WebViews do not pass origin credentials to Credential Manager. - Implement Chrome Custom Tabs (CCT) or Android Custom Tabs: CCT shares the user’s system Chrome session, Google Password Manager, and Passkey enclave.
In React Native:
import * as WebBrowser from 'expo-web-browser';
// Launch passkey onboarding via Chrome Custom Tab instead of internal WebView
export async function openSecurePasskeyAuth(authUrl: string) {
const result = await WebBrowser.openAuthSessionAsync(authUrl, 'myapp://callback');
return result;
}
- Deploy Digital Asset Links:
If you must use WebAuthn in an app that you own, deploy
/.well-known/assetlinks.jsonon your web server’s root:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.layerzerofault.wallet",
"sha256_cert_fingerprints": [
"14:6D:E9:7D:0F:52:CC:77:47:31:6E:86:E5:43:CD:28:B4:97:54:15:3A:42:F3:11:DF:E0:BC:23:45:67:89:AB"
]
}
}
]
Protocol 2: Web3 Frontend Defensive Retry Pattern
In your web application, intercept NotAllowedError and differentiate between a deliberate user cancellation and an OS-level lockout:
export async function authenticatePasskeyWithFallback(
options: CredentialRequestOptions
): Promise<Credential | null> {
const startTime = Date.now();
try {
const credential = await navigator.credentials.get(options);
return credential;
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
const elapsed = Date.now() - startTime;
// If the error returns in less than 250ms, the OS rejected the call before the user saw it!
if (elapsed < 250) {
console.warn('Android Credential Manager instant lockout detected.');
throw new Error(
'Biometric hardware locked or unauthorized WebView detected. Please unlock your device with your phone PIN or open this page in Chrome directly.'
);
} else {
// User saw the prompt and manually tapped "Cancel"
throw new Error('Authentication cancelled by user.');
}
}
throw err;
}
}
Protocol 3: End-User Quick-Fix for Biometric Lockout
If you are an end-user stuck in a NotAllowedError loop on Android:
- Force a PIN / Pattern Lock:
- Turn off your phone screen using the physical power button.
- Wake the screen and swipe to unlock.
- Do not use fingerprint or face unlock; enter your device PIN or password directly. This resets Android’s
Keyguardfailure counter.
- Verify Google Play Services Updates:
- Open Settings > Security & Privacy > System & Updates > Google Play system update.
- Install pending updates to ensure Credential Manager has the latest FIDO2 patches.
- Open in Native Chrome:
- If attempting to sign inside Telegram or Twitter, tap the three vertical dots
⋮in the top-right corner and select Open in Chrome.
- If attempting to sign inside Telegram or Twitter, tap the three vertical dots
3. Summary Diagnostic Checklist
- Verify that
rpIdmatches the second-level domain (e.g.,layerzerofault.siteinstead of sub-paths). - Verify that the server challenge has not timed out before
navigator.credentials.get()is invoked. - Ensure mobile applications launch Chrome Custom Tabs rather than sandboxed WebViews.
- Confirm that
/.well-known/assetlinks.jsonreturns HTTP200with Content-Typeapplication/json.