Fixing InvalidStateError on Registration

InvalidStateError thrown from navigator.credentials.create() looks alarming but is usually good news: it means the authenticator already holds a credential that matches your excludeCredentials list — the user is trying to enrol a device they already registered. The spec deliberately reports this generically to avoid leaking which credential matched, so you infer “already registered” from the fact that you sent the exclude list. This page shows how to handle that case as a benign outcome, and how to distinguish it from the rarer overlapping-ceremony cause. It sits under Debugging WebAuthn Protocol Errors.


Cause Signatures

Context Meaning Correct handling
create() with non-empty excludeCredentials Authenticator already holds an excluded credential Treat as “already enrolled” → route to sign-in
create() with empty excludeCredentials Rare; authenticator-specific state issue Retry once; log for investigation
Overlapping create()/get() in flight A prior ceremony was not aborted Abort prior ceremony; retry

The excludeCredentials mechanism (WebAuthn L2 §5.4.3) is what makes the first row by far the most common. It exists precisely to stop double-registration on one authenticator.


Root Cause Analysis

1. excludeCredentials matched (the common case). You correctly populated the exclude list with the user’s existing credential IDs; the authenticator recognised one of its own and refused to create a duplicate, throwing InvalidStateError. This is the system working as designed.

2. Overlapping ceremony. A pending conditional get() or a prior create() was not aborted before this call, so two ceremonies collide. This surfaces as InvalidStateError too — distinguish it by checking whether a ceremony was already in flight.

3. Stale exclude list omitting a synced credential. If a synced passkey exists on another device but your exclude list is built from a stale query, the user may partially double-register; keep the list current.


Step-by-Step Resolution

Step 1 — Populate excludeCredentials correctly

const options = await generateRegistrationOptions({
  rpID: 'example.com',
  userID: account.userHandle,
  userName: account.username,
  excludeCredentials: account.credentials.map((c) => ({
    id: c.credentialId,
    type: 'public-key',
    transports: c.transports,
  })),
});

Step 2 — Treat the matched case as success

try {
  const credential = await navigator.credentials.create({ publicKey: decode(options), signal: newCeremony() });
  await registerCredential(credential as PublicKeyCredential);
} catch (err) {
  const name = (err as DOMException).name;
  if (name === 'InvalidStateError') {
    // Already enrolled on this authenticator — not a failure
    showInfo('This device already has a passkey for your account.');
    routeToSignIn();
    return;
  }
  if (name === 'AbortError') return;
  throw err;
}

Step 3 — Rule out an overlapping ceremony

Ensure a single AbortController guards every ceremony (the pattern from resolving timeouts and AbortError), so InvalidStateError can be confidently attributed to excludeCredentials.

Step 4 — Keep the exclude list fresh

Rebuild excludeCredentials from a live query of the user’s credentials at each registration, including synced ones.


Verification and Testing

Reproduce deterministically with the DevTools WebAuthn virtual authenticator:

1. Register a credential on the virtual authenticator.
2. Call create() again with that credentialId in excludeCredentials.
   ⇒ InvalidStateError (expected "already enrolled")

Assert that your handler routes the InvalidStateError path to sign-in and does not render an error component. Add a test that an empty-excludeCredentials registration succeeds so you can distinguish the two causes.


Pitfalls

1. Showing an error for “already enrolled”. Route to sign-in instead; it is a benign outcome.

2. Empty excludeCredentials on re-registration. Populate it, or users double-register and you get duplicate credentials.

3. Blaming the authenticator for an overlap. Abort prior ceremonies first so the cause is unambiguous.


Related