Migrating from Non-Discoverable to Discoverable Credentials

Teams that adopted WebAuthn early often registered server-side (non-resident) credentials — great for second-factor flows, useless for usernameless autofill. Once you want one-tap passkey sign-in, you must migrate that base to discoverable resident keys. You cannot flip an existing credential in place: a credential’s resident-key nature is fixed at creation, so migration means enrolling a new discoverable credential alongside the old one and retiring the old one on a schedule. This page is that playbook. It sits under discoverable vs server-side credentials.


Constraint Reference

Constraint Detail Consequence
Resident-key nature is immutable Set by residentKey at create() Must re-register, not update
A user can hold both kinds Old server-side + new discoverable Migrate additively, no downtime
Roaming keys have finite slots ~25 resident keys on older keys May fail with ConstraintError
credProps.rk reports actual result Authenticators may downgrade preferred Store rk; don’t assume
userHandle must be stable Same opaque id across both credentials Account resolution stays consistent

Root Cause Analysis (why in-place upgrade fails)

1. Expecting a flag flip. There is no API to convert a stored server-side credential into a discoverable one; the authenticator never created a resident slot for it. The only path is a fresh create() with residentKey: 'required'.

2. Silent downgrade of preferred. Registering with residentKey: 'preferred' on a full roaming key produces a non-resident credential while appearing to succeed; without reading credProps.rk you think you migrated when you did not.

3. Losing account linkage. If the new credential uses a different userHandle, usernameless sign-in resolves to the wrong (or no) account.


Step-by-Step Resolution

Step 1 — Add a discoverable credential during an authenticated session

Prompt existing users, after they sign in with their current credential, to add a passkey with residentKey: 'required' and the same userHandle.

const options = await generateRegistrationOptions({
  rpID: 'example.com',
  userID: account.userHandle,       // SAME opaque id as the old credential
  userName: account.username,
  authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
  excludeCredentials: account.credentials.map((c) => ({ id: c.credentialId, type: 'public-key' })),
  extensions: { credProps: true },
});

excludeCredentials prevents enrolling twice on the same authenticator; it draws on the same list assembly used in credential revocation.

Step 2 — Verify the resident key was actually created

const ext = credential.getClientExtensionResults();
if (ext.credProps?.rk !== true) {
  // downgraded (e.g. full roaming key) — keep server-side, inform the user
  await markMigrationPending(account.id);
} else {
  await saveCredential({ /* … */, discoverable: true });
}

Step 3 — Switch the sign-in surface to usernameless

Once a user has a discoverable credential, enable conditional mediation autofill with an empty allowCredentials, while keeping the username-first path for users not yet migrated.

Step 4 — Retire old credentials on a schedule

Do not delete server-side credentials immediately; keep them as a fallback until the discoverable credential has been used successfully, then revoke on your normal lifecycle.


Verification and Testing

// Both credentials share a userHandle and resolve to one account
expect(newCred.userHandle).toEqual(oldCred.userHandle);
expect(newCred.discoverable).toBe(true);

In DevTools, register with a virtual authenticator that has resident-key support, confirm credProps.rk === true, then reload and verify the passkey autofills. Simulate a “full” authenticator (resident key unsupported) and confirm the migration is marked pending rather than falsely completed. Confirm usernameless sign-in resolves to the correct account via userHandle.


Pitfalls

1. Assuming preferred migrated the user. Read credProps.rk; treat false as not migrated.

2. New userHandle breaks account linkage. Reuse the stable opaque id.

3. Deleting the old credential too soon. Keep it until the new one is proven working.


Related