WebAuthn errors: NotAllowedError, InvalidStateError and more
WebAuthn reports every failure as a DOMException with a name. The message text differs between Chrome, Safari and Firefox, so branch on err.name and log the message for yourself. This page lists each name the spec throws
WebAuthn reports every failure as a DOMException with a name. The message text differs between Chrome, Safari and Firefox, so branch on err.name and log the message for yourself. This page lists each name the spec throws from navigator.credentials.create() (registering a passkey) and navigator.credentials.get() (signing in), what causes it in practice, and what to show the user.
try {
const cred = await navigator.credentials.create({ publicKey: options });
} catch (err) {
console.log(err.name, err.message); // "NotAllowedError", "The operation either timed out or was not allowed..."
}
Every WebAuthn error, at a glance
| err.name | create() | get() | Usual cause | What to do |
|---|---|---|---|---|
NotAllowedError |
Yes | Yes | Cancelled, timed out, no matching credential, blocked by policy | Show "Try again" and a fallback sign-in. Do not count it as a failed attempt |
InvalidStateError |
Yes | A credential in excludeCredentials is already on this authenticator |
Say the passkey is already set up | |
SecurityError |
Yes | Yes |
rp.id does not match the page's domain, or the page is not HTTPS |
Fix rp.id or serve the page over HTTPS |
NotSupportedError |
Yes | None of the algorithms in pubKeyCredParams is supported |
Include -7 (ES256) and -257 (RS256) |
|
ConstraintError |
Yes |
residentKey: "required" or userVerification: "required" and no authenticator can do it |
Use "preferred" unless you really need it |
|
AbortError |
Yes | Yes | Your AbortSignal was aborted |
Expected when you cancel; ignore it |
TypeError |
Yes | Yes | Bad options: user.id empty or over 64 bytes, a challenge that is not a buffer |
Fix the options your server sends |
NotAllowedError
This is the one you will see most, and it covers several causes on purpose. The spec makes the browser answer the same way whether the user cancelled or no credential exists, so a page cannot probe which accounts have passkeys.
Causes, in the order you are likely to hit them:
- The user cancelled the browser or system prompt, or pressed Escape.
-
The timeout ran out.
timeoutis in milliseconds and is a hint the browser may change. Five minutes (300000) is common. -
No credential matched (sign-in). You sent an
allowCredentialslist and none of those ids is on any authenticator the browser can reach: the passkey was deleted, was made on another phone that is not synced, or was made for anotherrpId. -
No user gesture. Safari in particular wants
create()andget()called straight from a click handler. Anawait fetch()before the call can lose the gesture; fetch the options first, then call on the click. -
A cross-origin iframe without permission. The iframe needs
allow="publickey-credentials-create; publickey-credentials-get".
What to do: show a short message ("Sign-in was cancelled. Try again or use another way") and keep a fallback visible. Never lock an account over NotAllowedError.
InvalidStateError
Only on create(). Your server listed the user's existing credential ids in excludeCredentials, which is right, and the authenticator recognised one of them. The user already has a passkey for this account on this device or in this password manager.
} catch (err) {
if (err.name === "InvalidStateError") return showMessage("You already have a passkey for this account on this device.");
throw err;
}
Keep sending excludeCredentials. Without it the same phone would save two passkeys for one account and the user could not tell them apart.
SecurityError
The browser checks that rp.id (on create()) or rpId (on get()) is the page's own domain or a parent domain of it:
| Page | rp.id | Result |
|---|---|---|
https://app.example.com |
example.com |
Works |
https://app.example.com |
app.example.com |
Works |
https://app.example.com |
login.example.com |
SecurityError |
https://example.com |
example.co |
SecurityError |
http://localhost:3000 |
localhost |
Works |
http://192.168.1.5:3000 |
anything | SecurityError (IP address) |
http://staging.example.com |
example.com |
Fails: not a secure context |
Two things follow. Pick the parent domain as rp.id from the start if you have several subdomains, because passkeys made for app.example.com will never work on www.example.com. And test on a phone through an HTTPS tunnel or a real domain, not your laptop's IP. Different domains that must share passkeys need Related Origin Requests, a /.well-known/webauthn file listing them.
NotSupportedError and ConstraintError
NotSupportedError means the authenticator supports none of the algorithms you offered. Offer both:
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256, every passkey provider
{ type: "public-key", alg: -257 }, // RS256, Windows Hello
],
ConstraintError means you demanded something no available authenticator can give: a discoverable credential (residentKey: "required") or user verification (userVerification: "required"), and the user only has, say, an old security key. "preferred" for both works with every passkey and still gives you a discoverable credential with verification on phones and laptops.
AbortError
Your own code aborted the request through the signal you passed. The usual case is passkey autofill: a conditional get() waits in the background, and when the user clicks a "Sign in with a passkey" button you abort it first, because only one WebAuthn request can run at a time. Catch AbortError and do nothing. See passkey autofill.
Errors from your server, not the browser
Some failures pass the browser and fail at verification. They are not DOMExceptions; your server library raises them:
-
Challenge mismatch. The challenge in
clientDataJSONis not the one you stored, often because two tabs started a sign-in. Store one challenge per attempt and expire it in a few minutes. -
Origin mismatch.
clientDataJSON.originishttps://www.example.comand you expectedhttps://example.com. List every origin your sign-in page is served from. In Android apps the origin isandroid:apk-key-hash:..., not your web domain. -
rpIdHash mismatch. The credential was made for another
rp.id. - Counter went backwards. Synced passkeys usually report a counter of 0. Accept 0; only treat a lower non-zero counter as suspect.
What SignOps does with these
SignOps Passkeys makes the options on its server: excludeCredentials from the user's existing passkeys, residentKey and userVerification set to "preferred", ES256 and RS256 offered, a fresh challenge per attempt, and the origin and rp.id checked against the domain and sign-in pages you list in the dashboard. Your page calls passkeys.register() or passkeys.signIn() from @signops/web and gets a token your server confirms. Setup: passkeys on your website, or test what your browser supports with the passkey checker.
Frequently asked questions
What does "NotAllowedError The operation either timed out or was not allowed" mean?
It is WebAuthn's catch-all. The user closed or cancelled the prompt, the timeout ran out, the page called the API without a user gesture inside a cross-origin iframe, a permissions policy blocked it, or, for a sign-in, none of the credentials in allowCredentials exist on any authenticator the browser can reach. The browser does not tell your page which, on purpose, so a site cannot learn whether a passkey exists.
Why does navigator.credentials.create() throw InvalidStateError?
The authenticator already holds a credential listed in excludeCredentials, so the user tried to register a second passkey for the same account on the same device or password manager. Treat it as "already set up" and show that, not as a failure.
What causes SecurityError "The relying party ID is not a registrable domain suffix of, nor equal to the current domain"?
The rp.id (or rpId) you passed is not the page's domain or a parent of it. A page on app.example.com can use example.com or app.example.com, never another domain. It also happens on an IP address or a non-HTTPS origin other than localhost.
Does WebAuthn work on localhost?
Yes. Browsers treat http://localhost as a secure context, so the API works there with rp.id set to localhost. Any other plain http origin fails, and so does an IP address such as 192.168.1.5.
What is the difference between NotAllowedError and AbortError?
AbortError comes from your own code, when the AbortSignal you passed is aborted, for example when you cancel a pending passkey autofill request to start a button sign-in. NotAllowedError comes from the user or the browser.
Sources: W3C: Web Authentication Level 3, create and get request exceptions, MDN: CredentialsContainer.create(), MDN: CredentialsContainer.get().
First published on signops.dev.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.