Fixing NotAllowedError in WebAuthn Ceremonies
NotAllowedError is the exception every WebAuthn integrator meets first and understands last. The spec intentionally routes several unrelated outcomes through it so the relying party cannot learn whether a credential exists — which means the error text tells you nothing, and diagnosis comes entirely from context you capture yourself. This page maps the real causes, gives a reproducible test for each, and shows the soft-fail handling that keeps a benign dismissal from becoming a broken screen. It sits under Debugging and Observability for Client-Side WebAuthn.
Cause Signatures
| Underlying cause | Distinguishing context | Fix class |
|---|---|---|
| Missing user activation | Modal get()/create() not inside a gesture; navigator.userActivation.isActive === false |
Move call into a click handler |
| Timeout elapsed | Elapsed ≈ configured timeout |
Raise timeout to 60–120 s |
| User dismissed prompt | Elapsed ≪ timeout; fast rejection |
Soft retry, offer password |
| RP ID / origin policy | Reproduces every time on a specific host | Fix rpId (may surface as SecurityError) |
| No matching credential | Empty allowCredentials result on a locked-down list |
Verify credential IDs / usernameless flow |
The NotAllowedError message is identical across all of these; the columns above are what you must log to tell them apart.
Root Cause Analysis
1. No user activation (the most common). A modal navigator.credentials.get() or .create() must run inside a user gesture. Firing it from a useEffect, a timer, or a promise chain that lost the activation window rejects immediately with NotAllowedError. (Conditional mediation is exempt — it is designed to run without a gesture.)
2. Timeout too short. The default and low custom timeout values cut off users who take a moment to authenticate, producing a NotAllowedError that looks like a dismissal.
3. Genuine dismissal. The user hit “Cancel” or closed the sheet. Benign — recover gracefully.
4. RP ID mismatch under some engines. A few engines surface an RP-ID/origin problem as NotAllowedError rather than SecurityError; if it reproduces deterministically on one host, verify the RP ID against the origin.
Step-by-Step Resolution
Step 1 — Guarantee a user gesture for modal ceremonies
// GOOD: call is synchronous within the click, before any await that could drop activation
button.addEventListener('click', async () => {
const options = await getAssertionOptions(); // fetch BEFORE? see note
await navigator.credentials.get({ publicKey: decode(options), signal });
});
If a pre-fetch await risks dropping activation on strict engines, fetch options before the click (e.g. on page load) so the get() is the first thing the handler does.
Step 2 — Set a generous timeout
const options = {
publicKey: { ...decoded, timeout: 90_000 }, // 90s; classify shorter elapses as dismissals
signal,
};
Step 3 — Soft-fail, don’t hard-fail
try {
await navigator.credentials.get(options);
} catch (err) {
if ((err as DOMException).name === 'NotAllowedError') {
reEnableButton();
revealPasswordFallback(); // never an error page for a dismissal/timeout
return;
}
throw err;
}
Step 4 — Rule out RP ID as a hidden cause
Use the origin/RP-ID check from the debugging cluster to confirm rpId is a registrable suffix of the origin before blaming the user.
Verification and Testing
Reproduce each cause deterministically with the Chrome DevTools WebAuthn virtual authenticator:
1. Missing gesture → call get() from setTimeout(…, 0) ⇒ NotAllowedError
2. Timeout → set timeout: 1, do not respond ⇒ NotAllowedError after ~1ms
3. Dismissal → virtual authenticator "cancel" ⇒ NotAllowedError fast
4. RP ID → set rpId to a non-suffix host ⇒ SecurityError / NotAllowedError
Assert in an integration test that a simulated dismissal re-enables the button and reveals the password path rather than routing to an error component. Log the classification (missing-user-gesture vs timeout vs user-dismissed) and confirm it matches the injected cause.
Pitfalls
1. Awaiting before the ceremony inside the click. A slow await can drop the activation window. Pre-fetch options so get() runs first.
2. Short timeouts masquerading as dismissals. Use 60–120 s and classify by elapsed time.
3. Fatal handling of a benign cancel. Soft-fail every NotAllowedError.
Related
- Debugging and Observability for Client-Side WebAuthn — the parent triage tree and telemetry approach
- Resolving WebAuthn Timeouts and AbortError — separating a real timeout from an expected abort
- Relying Party and Authenticator Roles — the RP ID and origin rules behind the policy-block cause