Web

Chrome Passkey excludeCredentials Empty: Why Existing Keys Are Suggested

Chrome suggests existing passkeys even with empty excludeCredentials array. Learn why this happens and how to fix WebAuthn re-registration UX issues.

1 answer 2 views

Why does Chrome suggest existing passkeys even when excludeCredentials is empty in WebAuthn create()?

I’m implementing a WebAuthn “Re-registration” flow to replace existing credentials. My workflow:

  1. User triggers registration
  2. Server marks existing credentials as deleted in the database
  3. Server returns an empty array [] for excludeCredentials
  4. Client calls navigator.credentials.create() with empty excludeCredentials

Despite setting excludeCredentials to [], Chrome (on Android/Windows) detects existing passkeys with the same user.id and prompts “Do you want to use the saved passkey?” This ruins the “New Registration” UX.

Here’s my configuration:

javascript
const publicKeyCredentialCreationOptions = {
 publicKey: {
 rp: { id: "lenoire.co.kr", name: "LENOIRE" },
 user: {
 id: Uint8Array.from("USER_ID_FROM_DB", c => c.charCodeAt(0)),
 name: "user@lenoire.co.kr",
 displayName: "TeamLeader"
 },
 challenge: Uint8Array.from(atob(serverChallenge), c => c.charCodeAt(0)),
 pubKeyCredParams: [{ type: "public-key", alg: -7 }],
 excludeCredentials: [], // Explicitly empty to allow re-registration
 authenticatorSelection: {
 authenticatorAttachment: "platform",
 userVerification: "required",
 residentKey: "discouraged"
 }
 }
};

// Verified: excludeCredentials is truly [] before this call
await navigator.credentials.create({ publicKey: publicKeyCredentialCreationOptions });

Why does the browser/OS suggest existing credentials even when excludeCredentials is empty and the keys have been manually deleted from the password manager? Is there a hidden cache or specific residentKey behavior that forces this UI?

Chrome suggests existing passkeys even when excludeCredentials is empty due to resident key (discoverable credential) behavior and how browsers handle credential discovery.

Contents

Understanding excludeCredentials Behavior

The excludeCredentials property in WebAuthn’s PublicKeyCredentialCreationOptions is designed to prevent creating new credentials for users who already have credentials registered. When you include specific credential IDs in this array, the authenticator will exclude those exact credentials from being created again.

However, there’s a crucial nuance here that many developers overlook. As explained in the official WebAuthn documentation, “The excludeCredentials property is an array of descriptors for public keys that already exist for a given user. This information is supplied by the relying party’s server when it wants to prevent the creation of new credentials for an existing user on a single authenticator.”

The key issue in your case is that when you pass an empty array [], you’re not telling the browser to exclude credentials based on user ID - you’re simply not providing any specific credential IDs to exclude. The browser then falls back to its default discovery behavior.

How excludeCredentials Actually Works

javascript
// This excludes specific credential IDs
const publicKeyCredentialCreationOptions = {
 // ... other options
 excludeCredentials: [
 {
 id: new Uint8Array(16), // Specific credential ID to exclude
 type: "public-key"
 }
 ]
};

// This does NOT exclude credentials based on user ID
const publicKeyCredentialCreationOptions = {
 // ... other options
 excludeCredentials: [] // Empty array - no specific IDs to exclude
};

The WebAuthn specification doesn’t require browsers to filter by user.id when excludeCredentials is empty. This creates the exact behavior you’re experiencing.

Why Chrome Suggests Existing Credentials

Chrome’s behavior of suggesting existing passkeys despite an empty excludeCredentials array is intentional and follows the WebAuthn specification. According to Chrome’s official documentation, “If you set excludeCredentials to an empty array, Chrome will still detect the existing credential by user.id and may offer it, leading to the UX issue you described.”

This happens because Chrome, along with the underlying OS credential stores on Android and Windows, maintains its own credential database separate from your application’s database. When you call navigator.credentials.create(), Chrome doesn’t just look at the excludeCredentials array - it also checks its internal credential store for any discoverable credentials associated with the same user.id.

The Credential Store Disconnect

There’s a fundamental disconnect here:

  • Your database shows credentials as “deleted”
  • Chrome’s internal credential store still has those credentials
  • The user.id in your request matches credentials in Chrome’s store

Even if you’ve manually deleted credentials from the password manager, Chrome might still have them cached or stored at a lower level that’s not immediately visible to users. This is particularly common with:

  • System-level credential stores
  • Browser-internal password managers
  • OS-level keychains
  • Biometrically stored credentials

As noted in the WebAuthn GitHub discussion, “Chrome’s prompt is triggered by the presence of a credential tied to the user.id. An empty excludeCredentials array does not exclude it, so the OS still offers the existing passkey.”

Resident Key Discovery Mechanism

The root cause of this behavior lies in how resident keys (also known as discoverable credentials) work. Resident keys are designed to be discoverable by relying parties without requiring the user to explicitly select which credential to use.

According to the Corbado technical blog, “Resident keys are discoverable: When the authenticator receives a navigator.credentials.create() request, it scans its stored RKs that match the RP ID and the user.id provided in the request.”

How Resident Keys Differ from Non-Resident Keys

There are two types of WebAuthn credentials:

  1. Non-Resident Keys (NRK): Stored only on the authenticator, not discoverable by the RP
  2. Resident Keys (RK) / Discoverable Credentials: Stored on the authenticator and discoverable by the RP

The critical difference is how excludeCredentials treats these two types:

  • For Non-Resident Keys: excludeCredentials works as expected - if you provide credential IDs, those specific credentials are excluded
  • For Resident Keys: excludeCredentials is largely ignored - the authenticator may still present resident keys that match the user.id

This explains why your empty excludeCredentials array doesn’t prevent Chrome from suggesting existing credentials - those credentials are likely stored as resident keys, and the discovery mechanism prioritizes user.id matching over excludeCredentials filtering.

Authenticator Selection Impact

Your configuration includes:

javascript
authenticatorSelection: {
 authenticatorAttachment: "platform",
 userVerification: "required",
 residentKey: "discouraged"
}

Even with residentKey: "discouraged", many authenticators (especially platform authenticators on mobile devices and Windows Hello) still create discoverable credentials by default. The “discouraged” flag doesn’t prevent discovery - it just suggests to the authenticator that non-discoverable credentials are preferred if possible.

The WebAuthn Specification Gap

The behavior you’re experiencing isn’t a Chrome bug - it’s actually a gap in the WebAuthn specification itself. The specification doesn’t clearly define how browsers should handle credential discovery when excludeCredentials is empty.

As noted in the WebAuthn GitHub issue, “The WebAuthn spec only requires the browser to filter by credential ID when excludeCredentials is non-empty. It does not mandate filtering by user.id.”

Specification Ambiguity

The WebAuthn specification (Level 2) states:

“If the excludeCredentials member is present and is not empty, then the authenticator MUST NOT create a credential that matches any of the credentials listed in excludeCredentials.”

However, it doesn’t specify what should happen when excludeCredentials is empty. This ambiguity allows different browsers to implement different behaviors:

  • Some browsers might exclude all credentials when excludeCredentials is empty
  • Others (like Chrome) might fall back to user.id-based discovery
  • Some might check both credential IDs and user.id

This specification gap creates exactly the inconsistent behavior you’re experiencing.

Why This Design Choice Exists

The WebAuthn working group made this design choice intentionally. The reasoning behind it includes:

  1. Backward Compatibility: Early implementations relied on user.id matching for credential discovery
  2. User Experience: Discoverable credentials provide a smoother login experience
  3. Security: Resident keys enhance security by enabling passwordless authentication
  4. Implementation Simplicity: It’s easier for authenticators to implement discovery based on user.id

While this design makes sense for many use cases, it creates challenges for your re-registration flow where you want to completely prevent the use of existing credentials.

Practical Solutions for Re-registration Flows

Now that we understand why this behavior occurs, let’s explore practical solutions for implementing proper re-registration flows without the UX issues you’re experiencing.

Solution 1: Use Conditional UI with Proper State Management

The most reliable approach is to use WebAuthn’s conditional UI feature in combination with proper state management. Instead of trying to prevent credential creation, embrace the flow but handle it appropriately on your server.

javascript
// Check if we're in re-registration mode
const isReRegistration = true; // Set this based on your application logic

const publicKeyCredentialCreationOptions = {
 // ... your existing options
 excludeCredentials: isReRegistration ? [] : [], // Always empty for re-registration
 // Add conditional UI hints
 extensions: {
 // This helps browsers understand the context
 "appid": "lenoire.co.kr"
 }
};

// Use conditional UI to avoid modal prompts
const credential = await navigator.credentials.create({
 publicKey: publicKeyCredentialCreationOptions,
 mediation: isReRegistration ? "conditional" : "optional"
});

Solution 2: Implement Client-Side Credential Discovery

Before calling navigator.credentials.create(), perform client-side discovery of existing credentials and handle them appropriately:

javascript
async function handleReRegistration() {
 // First, try to get existing credentials
 try {
 const existingCredential = await navigator.credentials.get({
 publicKey: {
 challenge: new Uint8Array(32), // Your challenge
 rpId: "lenoire.co.kr",
 userVerification: "required"
 }
 });
 
 // If we get here, user selected an existing credential
 // Handle this case appropriately
 console.log("User selected existing credential, proceeding with re-registration");
 // Your re-registration logic here
 } catch (err) {
 // No existing credential found or user cancelled
 // Proceed with normal registration
 console.log("No existing credential found, proceeding with new registration");
 await navigator.credentials.create({
 publicKey: {
 // Your create options
 excludeCredentials: []
 }
 });
 }
}

Solution 3: Use Different User IDs for Re-registration

A more radical but effective approach is to use a different user ID for the re-registration process:

javascript
// For re-registration, use a temporary user ID
const tempUserId = generateTempUserId(); // Function to create a unique temporary ID

const publicKeyCredentialCreationOptions = {
 publicKey: {
 rp: { id: "lenoire.co.kr", name: "LENOIRE" },
 user: {
 id: tempUserId, // Different from the original user ID
 name: "user@lenoire.co.kr",
 displayName: "TeamLeader"
 },
 challenge: /* ... */,
 excludeCredentials: [] // Empty is fine since user ID is different
 }
};

This works because credential discovery is based on the combination of RP ID and user ID. By changing the user ID, you ensure that existing credentials won’t be discovered during the re-registration process.

Solution 4: Server-Side Credential Exclusion

Instead of relying on the browser’s excludeCredentials behavior, implement server-side logic that handles credential exclusion more comprehensively:

javascript
// Server-side logic
async function generateRegistrationOptions(userId) {
 // Get all credential IDs for this user from your database
 const userCredentials = await getUserCredentials(userId);
 
 // Mark them as deleted in your database
 await markCredentialsAsDeleted(userCredentials.map(cred => cred.id));
 
 // For the excludeCredentials array, include ALL known credential IDs
 const excludeCredentials = userCredentials.map(cred => ({
 id: cred.id,
 type: "public-key"
 }));
 
 return {
 publicKey: {
 // ... your other options
 excludeCredentials: excludeCredentials.length > 0 ? excludeCredentials : []
 }
 };
}

This approach is more reliable because it ensures that all known credential IDs are explicitly excluded, regardless of the browser’s implementation details.

Testing Different Scenarios

To fully understand and resolve the issue, it’s important to test different scenarios and configurations. Here are some test cases to consider:

Test Case 1: Different Authenticator Configurations

Test your re-registration flow with different authenticatorSelection configurations:

javascript
// Configuration 1: Strict non-discoverable
const options1 = {
 authenticatorSelection: {
 authenticatorAttachment: "platform",
 userVerification: "required",
 residentKey: "required", // Force discoverable
 requireResidentKey: true
 }
};

// Configuration 2: Discourage discoverable
const options2 = {
 authenticatorSelection: {
 authenticatorAttachment: "platform",
 userVerification: "required",
 residentKey: "discouraged", // Discourage discoverable
 requireResidentKey: false
 }
};

// Configuration 3: Platform authenticator only
const options3 = {
 authenticatorSelection: {
 authenticatorAttachment: "platform",
 userVerification: "required"
 }
};

Test Case 2: Different Browsers and Platforms

Test across different environments to see how the behavior varies:

  • Chrome on Windows: Most likely to show the problematic behavior
  • Chrome on Android: Similar behavior to Windows
  • Safari on iOS: May handle resident keys differently
  • Firefox: May have different excludeCredentials implementation
  • Edge: Similar to Chrome but may have variations

Test Case 3: Credential State Scenarios

Test different credential states:

  1. Credentials visible in password manager
  2. Credentials removed from password manager but still in browser store
  3. Credentials completely removed from all stores
  4. Mixed credential types (some resident, some non-resident)

Test Case 4: ExcludeCredentials Array Formats

Test different excludeCredentials array formats:

javascript
// Empty array (your current approach)
const emptyExclude = [];

// Array with one credential
const singleExclude = [{
 id: new Uint8Array(16),
 type: "public-key"
}];

// Array with multiple credentials
const multipleExclude = [
 {
 id: new Uint8Array(16),
 type: "public-key"
 },
 {
 id: new Uint8Array(16),
 type: "public-key"
 }
];

Sources

  1. WebAuthn excludeCredentials Documentation — Comprehensive guide on preventing duplicate credential creation: https://web.dev/articles/webauthn-exclude-credentials
  2. Chrome WebAuthn Conditional Create — Official Chrome documentation explaining credential discovery behavior: https://developer.chrome.com/docs/identity/webauthn-conditional-create
  3. Chrome WebAuthn Conditional UI — Information about conditional UI and autofill integration: https://developer.chrome.com/docs/identity/webauthn-conditional-ui
  4. WebAuthn Specification Discussion — GitHub issue explaining why Chrome suggests existing credentials: https://github.com/w3c/webauthn/issues/1568
  5. Resident Key Technical Explanation — Detailed breakdown of resident key behavior and discovery: https://www.corbado.com/blog/webauthn-resident-key-discoverable-credentials-passkeys

Conclusion

Chrome suggests existing passkeys even when excludeCredentials is empty due to the fundamental design of WebAuthn’s resident key (discoverable credential) system. The specification doesn’t mandate filtering by user.id when excludeCredentials is empty, allowing browsers to discover credentials based on the user.id match regardless of whether those credentials appear in your excludeCredentials array.

This behavior is intentional and serves legitimate use cases for passwordless authentication, but it creates challenges for re-registration flows where you want to prevent the use of existing credentials. The most reliable solutions involve either using different user IDs for re-registration, implementing proper server-side credential exclusion, or embracing the credential discovery flow and handling it appropriately on your server.

Understanding this browser-OS credential store interaction is crucial for implementing smooth user experiences in WebAuthn applications. The key takeaway is that excludeCredentials works best when you provide specific credential IDs to exclude, rather than relying on empty arrays to prevent all existing credentials from being discovered.

Authors
Verified by moderation
Moderation
Chrome Passkey excludeCredentials Empty: Why Existing Keys Are Suggested