Fix: WebAuthn InvalidStateError & Multi-Device Passkey Credential ID Collisions
In modern Web3 applications leveraging Account Abstraction (ERC-4337) and embedded passkey signers (such as Coinbase Smart Wallet, Turnkey, Privy, or ZeroDev), providing seamless multi-device recovery is a primary architectural goal. Users are encouraged to register both a primary authenticator (e.g. Apple TouchID via iCloud Keychain) and a secondary fallback authenticator (e.g. Windows Hello TPM, Android biometrics, or an external FIDO2 YubiKey).
However, developers frequently encounter an abrupt client-side exception during the secondary registration flow:
DOMException: The authenticator has already registered such credentials.
Code: 11
Name: InvalidStateError
Alternatively, on the smart contract layer, registering the secondary device silently overwrites the primary passkey’s public key coordinates, leaving the primary device unable to sign subsequent UserOperations.
If you are debugging iframe communication barriers during passkey enrollment, see our guide on WebAuthn Permissions Policy publickey-credentials-create Revert.
Technical Mechanics: The excludeCredentials Mandate
Under Section 5.1.3 of the W3C WebAuthn Level 3 specification, the excludeCredentials parameter is designed to prevent a single physical authenticator from accumulating multiple distinct passkey credentials for the same Relying Party (RP) user account:
// The standard credential creation options passed to navigator.credentials.create()
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
challenge: randomChallengeBuffer,
rp: { name: "LayerZeroFault Smart Wallet", id: "layerzerofault.site" },
user: {
id: userUuidBytes,
name: "user@layerzerofault.site",
displayName: "Smart Account 0x742d..."
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }], // ES256 (secp256r1)
excludeCredentials: [
{
id: existingCredentialIdBytes, // ← THE CULPRIT
type: "public-key",
transports: ["internal"]
}
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required"
}
};
When the browser invokes navigator.credentials.create():
- The browser passes the
excludeCredentialslist to the target authenticator. - If the authenticator already holds any credential whose ID matches an entry in
excludeCredentials, the authenticator must abort registration immediately and return a status code ofInvalidStateError. - If the frontend indiscriminately copies all existing user credentials into
excludeCredentialswithout filtering by device context, any attempt to update permissions or create an alternative key on a synced ecosystem (like Apple iCloud Keychain across Mac and iPhone) hits this wall.
Smart Contract Vulnerability: Slot Overwrite vs Key Registry
On the smart contract side, modular Account Abstraction validators (such as ERC-7579 or custom P256 verification modules) often store passkey signers in a fixed storage slot:
// VULNERABLE CONTRACT PATTERN: Single-slot or naive indexing
contract VulnerablePasskeyValidator {
struct WebAuthnPublicKey {
uint256 pubKeyX;
uint256 pubKeyY;
}
// Overwritten whenever a new device is registered!
mapping(address => WebAuthnPublicKey) public accountPasskeys;
function addSigner(bytes calldata signature, uint256 newX, uint256 newY) external {
// Authenticate existing signer...
accountPasskeys[msg.sender] = WebAuthnPublicKey(newX, newY);
}
}
When the secondary device is registered, accountPasskeys[account] is overwritten. The user’s primary device (which still holds its passkey locally) attempts to sign a UserOperation, but the contract evaluates the P256 signature against the secondary device’s public key coordinates, resulting in an unrecoverable AA23 reverted error.
The Complete Remediation Protocol
Step 1: Intelligent Frontend Credential Exclusion
When adding a secondary authenticator, filter excludeCredentials based on the targeted authenticator attachment:
// SECURE PATTERN: Context-aware excludeCredentials builder
export function buildCreationOptions(
userId: Uint8Array,
targetAttachment: "platform" | "cross-platform",
existingCredentials: Array<{ id: Uint8Array; attachment?: string; transports?: AuthenticatorTransport[] }>
): PublicKeyCredentialCreationOptions {
// Only exclude credentials that belong to the SAME hardware category
const filteredExclusions: PublicKeyCredentialDescriptor[] = existingCredentials
.filter(cred => {
// If registering a roaming security key (YubiKey), DO NOT exclude platform (TouchID/Windows Hello) keys
if (targetAttachment === "cross-platform") {
return cred.transports?.includes("usb") || cred.transports?.includes("nfc");
}
// If registering a platform passkey, only exclude platform credentials
return cred.attachment === "platform" || cred.transports?.includes("internal");
})
.map(cred => ({
id: cred.id,
type: "public-key",
transports: cred.transports
}));
return {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { id: window.location.hostname, name: "Modular Web3 Wallet" },
user: { id: userId, name: "user-session", displayName: "Wallet Signer" },
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: targetAttachment,
userVerification: "required",
residentKey: "preferred"
},
excludeCredentials: filteredExclusions
};
}
Step 2: Deterministic Key Mapping in Smart Account Contracts
To support arbitrary secondary authenticators without collision, map public keys by their WebAuthn Credential ID hash:
// SECURE PATTERN: Credential ID-addressed Multi-Passkey Validator
contract SecureMultiPasskeyValidator {
struct WebAuthnSigner {
uint256 pubKeyX;
uint256 pubKeyY;
bool isActive;
}
// Mapping: Account => (keccak256(credentialId) => Signer)
mapping(address => mapping(bytes32 => WebAuthnSigner)) public accountSigners;
event PasskeyAdded(address indexed account, bytes32 indexed credHash, uint256 pubKeyX, uint256 pubKeyY);
event PasskeyRevoked(address indexed account, bytes32 indexed credHash);
function addPasskeySigner(
bytes32 credHash,
uint256 pubKeyX,
uint256 pubKeyY
) external {
require(msg.sender == address(this), "Only self-authorized execution");
require(!accountSigners[msg.sender][credHash].isActive, "Signer already active");
accountSigners[msg.sender][credHash] = WebAuthnSigner({
pubKeyX: pubKeyX,
pubKeyY: pubKeyY,
isActive: true
});
emit PasskeyAdded(msg.sender, credHash, pubKeyX, pubKeyY);
}
}
Defensive Engineering Checklist
- Catch
InvalidStateErrorGracefully: TreatInvalidStateErrorin your UI as an informative prompt (“This device or iCloud account already has a registered passkey”) rather than a generic fatal error. - Dual-Curve Support: Allow the secondary signer to be either ES256 (secp256r1 via P256) or RS256 (for enterprise Windows Hello configurations) without disrupting existing keys.
- Recovery Quorum: Require confirmation from the primary passkey (or a social recovery guardian) before committing secondary passkey coordinates into on-chain contract state.
Fact-Checked by Victor Vance, Senior Smart Contract Security Analyst & Node Operator.