Passkeys in .NET 10: A Subtle Interop Issue with 1Password (and How We Fixed It)
With .NET 10, ASP.NET Core Identity introduces support for WebAuthN Passkeys. It’s a big step forward for passwordless authentication, although standards implemented by various vendors can lead to interoperability issues even with slight misinterpretations. The latest one I discovered was while preparing for a demo showing off Passkeys with 1Password.
While implementing Passkey authentication, I ran into an unexpected issue when using credentials stored in 1Password.
The Problem
Most of the time, for demos, I use Apple Keychain to store Passkeys, but this time I wanted to show that Passkey registration works with 1Password. Unfortunately, this failed to register the key
“The assertion credential JSON had an invalid format… missing required properties including: 'clientExtensionResults'.”
This all points to a server-side deserialization issue; theclientExtensionResultsproperty is missing from the credential payload generated by the WebAuthN API in the browser. In most cases, this is empty and not used in the core Passkey authentication process. However, the .NET JSON serializer used by the .NET 10 Passkey implementation requires it to be present, even if empty. When using a physical store or Apple Keychain, this property is present and empty and not a problem, but for 1Password, it is missing, which causes the .NET JSON serializer to break.
Not A Simple Fix
I first tried to simply add the missing property to the credential object returned from WebAuthN before calling JSON.stringify(). However, it turns out that the object returned from the call navigator.credentials.get is not a regular JavaScript object, and adding properties to the returned object will not be visible to JSON.stringify. So that didn't work.
The Adopted Solution
I moved to a DTO-based approach on the client and explicitly constructed the payload, by mapping the various parts from the credential object returned from WebAuthN into a new object that would always meet the requirements of the JSON serializer.
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
. . .
const credential = await navigator.credentials.create({ publicKey:options });
const payload = {
id: credential.id,
rawId: bufferToBase64(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
attestationObject: bufferToBase64(credential.response.attestationObject)
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {}
};
// Send payload to server for validation, and storage
Once I solved this for registration, I met the same issue with authentication, so I needed to apply the same technique for credentials generated by navigator.credentials.create and navigator.credentials.get
const credential = await navigator.credentials.get({ publicKey:options });
const payload = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature),
userHandle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {}
};
// Send payload to server for sign-in
Why This Works
This solution works by taking control of the serialisation and not relying on each WebAuthN authentication provider to provide a response acceptable by the .NET 10 Passkey implementation. So while this is not as simple as calling JSON.stringify, it gives you more confidence that whatever WebAuthN authenticator your users are using, they will be able to authenticate and save you from those strange support calls.
Key takeaways:-
- Ensures all required properties are present
- Produces base64url-encoded values expected by ASP.NET
- Avoids relying on browser object serialization quirks
Alternatively, Fix It Server-Side?
We briefly considered patching the JSON server-side, but rejected it because it would put more burden on the server to handle this edge case. It felt more scalable to make each browser instance format the payload to an acceptable format before sending it to the Passkey authentication API.
Fixing the payload at the source felt cleaner and scales.
Closing Thoughts
This is a great example of how multiple vendors interpret standards can expose subtle interoperability issues. It further turned out that using the QR code feature and my iPhone with 1Password worked fine; I only had the issue with my desktop version of 1Password.
If you’re implementing passkeys in .NET 10, it’s worth validating your payloads early, against a variety of WebAuthN authenticators, especially if you expect users to rely on tools like 1Password.
Have you hit similar issues with passkeys or WebAuthn? I’d be interested to hear how others are handling cross-platform quirks.
IdentityServer