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