Error gallery
WebAuthn's errors are deliberately vague — this is a privacy feature, not
sloppiness. If "no authenticator found" and "you have a passkey but declined to
use it" produced different errors, a malicious page could use that difference to
figure out whether you own a passkey for some other site. So the browser
collapses most failures into one DOMException named NotAllowedError, and production code has to handle that ambiguity
honestly rather than pretend it can always say exactly what went wrong.
User cancels, or the ceremony times out
Dismissing the prompt and letting the clock run out produce the identical error. In practice you mostly don't need to tell them apart — both mean "this attempt didn't happen," and the right response in either case is usually just letting the visitor try again.
A 3-second timeout — either dismiss the prompt yourself, or just wait it out. Both produce the exact same error; the API gives no way to tell them apart.
Registering a credential that already exists
This one is distinguishable — InvalidStateError, not NotAllowedError — because it isn't a privacy-sensitive signal: you
already know you have this credential, you just tried to create it again.
This sends the exact same registration request as the Registration page — the server's excludeCredentials list already includes every credential you've registered in this session, precisely to catch this case.
No matching authenticator available
Asking for a specific authenticatorAttachment the current device
doesn't have (or forgetting a phone/security key at home) also surfaces as NotAllowedError — genuinely indistinguishable, from the API alone,
from a plain cancel.
Requests a cross-platform (roaming) authenticator specifically. Unlike the other demos on this page, the result here is genuinely device-dependent — if this device has a security key or a phone offering the hybrid/QR flow, it may well succeed instead of failing.
RP ID mismatch
This one throws a distinct SecurityError, synchronously, before any
authenticator is involved — different enough in cause and timing that it
deserves its own page. See RP ID and origins.
The practical pattern
try {
const credential = await navigator.credentials.create({ publicKey });
// ...
} catch (err) {
if (err instanceof DOMException) {
switch (err.name) {
case 'NotAllowedError':
// Cancelled, timed out, or no matching authenticator — treat uniformly:
// let the visitor retry, don't imply which of the three happened.
break;
case 'InvalidStateError':
// This exact credential already exists — safe to say so explicitly.
break;
case 'SecurityError':
// Origin/RP ID configuration problem — a bug, not a user action.
break;
default:
// Something else — log it, but still fail gracefully for the visitor.
}
}
}