Errors

Every PasskeyError code, what causes it, and what to do about it.

Everything thrown on either side of the wire is a PasskeyError carrying a stable machine-readable code.

import { PasskeyError, isPasskeyError } from 'passkify';         // server
import { PasskeyError, isPasskeyError } from 'passkify/client';  // browser

Why codes rather than message matching

The class

PasskeyErrorclass

class PasskeyError extends Error
codePasskeyErrorCoderequired
statusnumber
causeunknown
isUserCancellationboolean (getter)
toJSON()() => { error, message }
try {
  await login();
} catch (error) {
  if (!isPasskeyError(error)) throw error;
 
  if (error.isUserCancellation) return;              // not worth surfacing
  if (error.code === 'unknown_credential') return showSignUpPrompt();
 
  showMessage(error.message);
}

Browser codes

Thrown by passkify/client.

unsupportedcode

No WebAuthn in this browser or context.

Do: feature-detect with isSupported() before rendering a passkey button, and keep another way to sign in for the browsers that fail it.

cancelledcode

The prompt was dismissed, timed out, or was superseded by another ceremony.

Do: nothing. This is the most common outcome after a successful login, and showing an error for it trains users to ignore your errors.

already_registeredcode

The authenticator already holds a passkey for this account and declined to create a duplicate. Raised from InvalidStateError.

Do: show "this device already has a passkey for your account". This is information, not a failure, and it means excludeCredentials did its job.

insecure_contextcode

Not a secure origin, or rpID does not match the page's domain.

Do: check you are on HTTPS or localhost, then check rpID against the table. This is the error you get from an rpID typo.

not_allowedcode

The authenticator refused for a reason it will not specify. Usually a policy mismatch: user verification was required and unavailable, or a discoverable credential was required and there was no room.

server_errorcode

Your own endpoint was unreachable, returned a non-2xx without a JSON body, or returned something unparseable.

Do: check baseUrl matches the adapter's basePath. A mismatch is the usual cause and the fetch handler's 404 body says so explicitly.

Server codes

Thrown by passkify/server. The status column is what the adapters send.

CodeStatusCause
malformed_response400Not a ceremony response, or a required field is missing
parse_error400Bad CBOR, truncated authenticator data, invalid UTF-8
challenge_not_found401Expired, already used, or issued by another process
challenge_mismatch401The signed challenge is not the one issued
origin_mismatch401Origin not in the allow-list, or a cross-origin frame
type_mismatch401Login response sent to registration, or the reverse
rpid_mismatch401Signed for a different Relying Party ID
user_not_present401The user-present flag was not set
user_not_verified401Verification required, only presence performed
bad_signature401The signature did not verify
counter_regression401Counter went backwards, possible cloned authenticator
unsupported_algorithm401Key algorithm not offered, or not verifiable
attestation_failed401Attestation statement present but inconsistent
unknown_credential404Not registered, or not for this account
unknown_user404No such account, or no session on a protected route
credential_exists409Already registered, or the username is taken
last_credential409Refusing to remove an account's only passkey
unsupported_feature500Valid but unimplemented, such as an exotic attestation format
configuration_error500passkify is misconfigured

The ones you will actually hit

challenge_not_foundcode

No pending ceremony matches this response.

Three causes, in order of likelihood:

  1. Multiple workers with MemoryStore. Worker A issued the challenge, worker B received the response. Move challenges to shared storage. This is the answer roughly every time the failure is intermittent in production and absent locally.
  2. The response was replayed. Working as intended.
  3. The user took longer than challengeTimeout. Raise it if your flow involves a cross-device scan.

origin_mismatchcode

The browser reported an origin not in your allow-list, or the ceremony ran inside a cross-origin frame.

Do: compare the origin in the message against your origin config, character for character. https://example.com and https://www.example.com are different origins, and so are http://localhost:3000 and http://localhost.

Behind a proxy, the browser reports the public origin, not the internal one your app binds to.

rpid_mismatchcode

The authenticator signed for a different Relying Party ID.

Do: if this appears at login for a credential that used to work, your rpID changed. There is no migration; those credentials are orphaned. If it appears at registration, the configured rpID is not valid for the page's origin, and the constructor would normally have caught that, so check the page is served from the origin you configured.

last_credentialcode

Refusing to remove an account's only passkey, which would lock the user out.

Do: tell the user to register a replacement first. This is a conflict with the account's state rather than a fault, so it is a 409 and belongs in the UI, not in your error log.

If your app has another way in, and being left with zero passkeys is fine, call store.deleteCredential(id) directly. The guard lives in the server method, not the store.

configuration_errorcode

passkify is set up wrong, and the message names the fix. Raised at construction for a bad rpID or origin, and at runtime for a user handle over 64 bytes.

Mapping to your API

The adapters do this for you. Doing it by hand:

import { isPasskeyError } from 'passkify';
 
app.use((error, req, res, next) => {
  if (!isPasskeyError(error)) return next(error);
 
  // 5xx means passkify itself broke; let it reach your logs.
  if (error.status >= 500) return next(error);
 
  res.status(error.status).json(error.toJSON());
});