Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

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:

  1. The user cancelled the browser or system prompt, or pressed Escape.
  2. The timeout ran out. timeout is in milliseconds and is a hint the browser may change. Five minutes (300000) is common.
  3. No credential matched (sign-in). You sent an allowCredentials list 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 another rpId.
  4. No user gesture. Safari in particular wants create() and get() called straight from a click handler. An await fetch() before the call can lose the gesture; fetch the options first, then call on the click.
  5. 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 clientDataJSON is 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.origin is https://www.example.com and you expected https://example.com. List every origin your sign-in page is served from. In Android apps the origin is android: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.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.