Troubleshooting

NotAllowedError, origin_mismatch, challenge_not_found, testing on a phone.

Symptoms, causes, fixes. Ordered roughly by how often each comes up.

The prompt never appears, and you get cancelled immediately

Almost always rpID. It must be the page's host or a parent domain of it.

Page served fromValid rpIDInvalid
https://example.comexample.comapp.example.com, www.example.com
https://app.example.comapp.example.com, example.comother.example.com
http://localhost:3000localhost127.0.0.1

passkify checks this against your origin at construction, so if the server started, the configured rpID is consistent with the configured origin. Check that the configured origin is where the page is really served from.

console.log(passkeys.rpID);      // what the server thinks
console.log(location.origin);    // what the browser sees

origin_mismatch on finish

The origin list must contain the exact string the browser reports, scheme and port included. All of these are different origins:

https://example.com
https://www.example.com
http://localhost:3000
http://localhost

The error message names the origin that was rejected. Compare it character for character against your config.

challenge_not_found, intermittently, only in production

MemoryStore behind more than one worker. Worker A issued the challenge, worker B received the response and cannot see it.

This is the answer essentially every time the failure is intermittent in production and absent locally, because locally you run one process.

Fix: move challenges to shared storage. Redis with GETDEL is a good fit; see store adapters.

Other causes, in order:

  • The response was replayed. Working as intended.
  • The user took longer than challengeTimeout, which defaults to five minutes. Raise it if your flow involves finding a phone and scanning a QR code.

already_registered when adding a second passkey

Expected. excludeCredentials told the authenticator it already holds a credential for this account, and it declined to make a duplicate.

Show "this device already has a passkey for your account" rather than an error. It means the mechanism worked.

login() shows no passkeys, though one is registered

Two causes:

The credential is not discoverablemost likely
rpID changed since registrationunrecoverable

The second ceremony on a page fails

Browsers allow exactly one outstanding WebAuthn request, and a signInWithAutofill() left running counts.

passkify aborts the pending one automatically when you call register() or login(). If you call navigator.credentials directly elsewhere, call cancelPendingCeremony() first.

In React, abort on unmount:

useEffect(() => {
  const controller = new AbortController();
  signInWithAutofill({ signal: controller.signal }).catch(() => {});
  return () => controller.abort();
}, []);

The autofill dropdown never offers passkeys

Check all three:

  1. The input has autocomplete="username webauthn". Both tokens, in that order.
  2. await isAutofillAvailable() returns true.
  3. signInWithAutofill() was actually called, and its promise is not being awaited somewhere that blocks.

Testing on a phone

Phones need real HTTPS; localhost will not do. Use a tunnel:

cloudflared tunnel --url http://localhost:3000
# or
ngrok http 3000

Then set origin and rpID to the tunnel's hostname and restart.

unsupported thrown on the server

You imported passkify/client into server-rendered code, or passkify into browser code.

  • passkify and passkify/server are Node only, and pull in node:crypto.
  • passkify/client is browser only, and touches navigator.credentials.

In Next.js, ceremonies belong in a 'use client' component or an event handler.

Types do not resolve

Set moduleResolution to bundler, node16 or nodenext. The classic node resolution mode predates subpath exports and cannot see passkify/client.

{ "compilerOptions": { "moduleResolution": "bundler" } }

bad_signature on every login

If it is happening for every credential rather than one, suspect the stored public key rather than the ceremony.

import { parseCOSEPublicKey, fromBase64Url, algorithmName } from 'passkify';
 
const credential = await store.getCredentialById(id);
const key = parseCOSEPublicKey(fromBase64Url(credential.publicKey));
console.log(algorithmName(key.alg));   // should print, not throw

If that throws, publicKey is not round-tripping through your database. The usual cause is a column typed as something that mangles the string, or a driver returning a Buffer where a string is expected.

Still stuck

Log these four and compare them against each other:

console.log({
  configuredRpID:  passkeys.rpID,
  configuredOrigin: process.env.ORIGIN,
  browserOrigin:   location.origin,     // from the browser
  errorCode:       error.code,
});

Nearly every remaining case is a disagreement between the first three.