Skip to main content

We Almost Migrated to .NET 10's Built-In Passkeys

Passkeys are replacing passwords across major consumer platforms. Apple, Google, and Microsoft ship passkey support in their products, password managers such as 1Password, Bitwarden, and Dashlane sync passkeys across devices, and a widening set of consumer services and enterprises now accept them. The FIDO Alliance coordinates this rollout industry-wide. The reason: passkeys are phishing-resistant by design, faster to use than passwords, and impossible to leak from a server breach.

What this looks like in practice. A user registers a passkey on his iPhone with Face ID, then signs in on a Windows laptop by scanning a QR code from his phone. A 1Password vault syncs the same credential across devices. A workplace handing out YubiKeys gets the same flow with a hardware key. A user typing his password on a lookalike phishing domain is fooled; a passkey on the same domain silently refuses to release. Credential theft via phishing, one of the leading vectors for account takeover, stops working.

For any service signing in real users, supporting passkeys has moved from frontier to baseline.

At Abblix we develop and ship two things: Abblix OIDC Server, a certified OAuth 2.0 and OpenID Connect library for .NET, and Abblix Account, a full authentication service built on top of it that owns the user-facing login UI, MFA support and account management surfaces. End to end, passkey sign-in is live today: a user registers a passkey, sees it labelled by device type in the account UI, and uses it to sign in to any OIDC client federated to us.

Microsoft shipped built-in passkey support in ASP.NET Core 10. We had already been running passkey sign-in in Abblix Account on Fido2NetLib. The smart move would have been to switch.

Microsoft never claimed the .NET 10 passkey API reaches beyond ASP.NET Core Identity; the implementation is deliberately scoped there, and we walk through what that scope looks like in detail below. Even so, for a team that builds and ships its own auth stack on .NET, the question of whether we could put it to work in our OIDC Identity Provider was a practical one, and online discussions around the new API suggest we were not the only ones to pose it. This article is the answer we found.

So we read the source. Then we built the adapter and ran it.

TL;DR

Shipping native passkey support in the framework is the right move, and a clear step forward. The .NET 10 release works well for Blazor consumer apps built on ASP.NET Core Identity, which is exactly the scenario it targets.

For an OIDC Identity Provider with its own auth stack, the difficulty is the orientation of the whole design: it is built around one assumed case, Blazor on ASP.NET Core Identity with Entity Framework, and the API decisions follow from that without much regard for other use cases.

Some of the resulting constraints can be worked around; the sharpest that cannot is the ceremony's binding to HttpContext, which for any stack that already binds requests into typed contracts means a second, untyped data path running parallel to the real one, for the same request. Two configuration gaps add to it, no reachable EdDSA and no default attestation pipeline, both of which Fido2NetLib already covers. Together they made migration premature for us, so we stayed on Fido2NetLib for now.

None of these is intrinsic to WebAuthn, and even where individual limits are bypassable the design needs generalising beyond that one scenario; the highest-impact step is an abstract ceremony that does not require an HttpContext, with a turnkey path beyond Blazor.

What ASP.NET Core 10 ships

The new API is a set of passkey methods on SignInManager. Persistence uses a new table introduced in ASP.NET Core Identity schema version 3, so an existing database needs a migration to support passkeys.

Microsoft is explicit about the scope. The documentation states the implementation "is deliberately scoped to authentication scenarios", "isn't intended as a general-purpose WebAuthn library", and that developers needing full WebAuthn functionality "should consider community libraries that provide comprehensive protocol support". That boundary is stated and intentional, and structural: every passkey type is declared in namespace Microsoft.AspNetCore.Identity (handler, options, stores, even transport-agnostic WebAuthn primitives such as CredentialPublicKey), and there is no Microsoft.AspNetCore.WebAuthn namespace at all. The naming reflects a design decision to treat passkeys as a feature of ASP.NET Core Identity rather than a standalone capability, and that decision propagates into the API shape and limits the implementation's reach beyond ASP.NET Core Identity scenarios. That is the fair way to read everything that follows: the question is what that claimed scope costs an Identity Provider in practice, and where it could widen.

Configuration goes through IdentityPasskeyOptions, which exposes properties including delegate-typed hooks. Two of them (IsAllowedAlgorithm, VerifyAttestationStatement) come up below.

Out of the box, only the Blazor Web App project template ships a ready integration. For any other front-end (MVC, Razor Pages, SPA with a separate API), the developer wires up the JavaScript ceremony and the endpoints on his own, using the SignInManager methods as the building blocks. Under the hood, the creation step builds the WebAuthn pubKeyCredParams array from a fixed list, filtered by IsAllowedAlgorithm if configured; attestation validation runs through a separate step with an optional VerifyAttestationStatement hook.

The flow is straightforward and the extension points are documented. For a consumer-grade Identity application running on Blazor, this is good enough. An Identity Provider with its own auth stack reads it differently, and ours has been running on a different foundation.

Our baseline

A user of Abblix Account might register multiple authenticators over time. A typical flow looks like this. He registers a YubiKey 5C as his primary passkey on a work laptop. The next week they add an iPhone with Face ID as a backup, scanning a QR code from the work laptop. A month later they store a synced passkey in 1Password for cross-device convenience.

On the server side the requirements are roughly these. From the very beginning we decided to prefer strength-first algorithms, with weaker ones kept only for compatibility. EdDSA (Ed25519) should be available, because YubiKey firmware 5.2.3 and later support it and Ed25519 signatures are deterministic and verify faster than ECDSA. Attestation should be processed, because some scenarios require restricting which authenticator models can register.

This is an OIDC IdP role with multiple authenticators per user as a daily requirement, where policy controls matter, and we implemented it before the .NET 10 built-in API existed.

The result in our appsettings.json looks like this, where each Alg is a COSE algorithm identifier from the IANA COSE Algorithms registry (negative integers by convention for signature algorithms):

"PubKeyCredParams": [
{ "Alg": -36 }, // ES512, 256-bit security
{ "Alg": -35 }, // ES384, 192-bit security
{ "Alg": -8 }, // EdDSA, Ed25519
{ "Alg": -7 }, // ES256, universal floor
{ "Alg": -257 } // RS256, kept for Windows Hello compatibility
],
"ResidentKey": "Required",
"UserVerification": "Preferred"

Five algorithms, strongest-first, with RS256 last specifically because Windows Hello requires it on first registration. An authenticator that supports several entries from this list picks the strongest it can. The ceremony itself runs through Fido2NetLib, wired into our authentication pipeline; credentials persist through our existing data layer.

We tried to run the migration

To check the design against reality rather than against documentation, we took a feature branch and actually attempted the migration. It surfaced three obstacles, each reproduced by running code against the shipped ASP.NET Core 10 assemblies, and confirmed one further limitation MS had already documented. The runtime findings are observed by running the code.

The store coupling: bridgeable

The default handler is hard-wired to UserManager, but the coupling proved soft, bypassed without much trouble. PasskeyHandler is sealed, and its only public constructor takes (UserManager<TUser>, IOptions<IdentityPasskeyOptions>), with no store-only or parameterless path.

Storage, by contrast, is not bound to anything. It goes through the IUserPasskeyStore interface, and the only shipped implementation of that interface is the Entity Framework Core one persisting to the AspNetUserPasskeys table on IdentityDbContext (a type that lives in the separate Microsoft.AspNetCore.Identity.EntityFrameworkCore package, not the core contract). That implementation is a default: the interface knows nothing about EF, and a passkey row is the plain UserPasskeyInfo model. For an existing IdP with its own user store, adopting this means either migrating the whole auth stack onto ASP.NET Core Identity, a rewrite that breaks the OIDC architecture, or writing an adapter that exposes our store as the Identity interfaces over whatever data access we already use.

We built the adapter to learn which. We implemented IUserStore together with the passkey-specific IUserPasskeyStore over a stand-in user model with no Identity database behind it, wired UserManager around that store, and stood up the sealed PasskeyHandler with the resulting manager. Both constructed and ran. No Entity Framework, no IdentityDbContext, and no database were involved; a Dapper-backed implementation of the same two interfaces, against our existing schema, is the identical shape.

One structural wrinkle is worth naming for codebases that separate reads from writes: each Identity store interface bundles queries and mutations together, so a CQRS design cannot map the adapter onto a single read or write path and instead fans each method to the matching side behind one class. That is a known cost of fitting a store-shaped contract into a command/query split, not a blocker. So this obstacle is bridgeable, proven by execution rather than by inspection. What it leaves behind is a smell. Nothing in the surrounding shape was built with a non-Identity, non-EF stack in view, even though the contract underneath stays open, and the assumption is not hidden. The price of declining it is a bounded but ongoing adapter, maintained against a user model that otherwise has no reason to know these interfaces exist. Fido2NetLib has no equivalent of this: it operates on raw WebAuthn objects and never touches a user store, so credentials live in whatever store the application already has and there is no adapter to write at all. Clearing the built-in coupling does not unblock the migration by itself; the next obstacle stands regardless of how the user store is supplied.

The HttpContext binding: the real blocker

The ceremony's binding to an HttpContext does not bridge cleanly. With the handler constructed, we called the credential-creation step. The method only proceeds when handed an HttpContext. Availability is not the difficulty. We can obtain a real HttpContext, or fabricate one, and hand it over; that part is trivial. The difficulty is what handing one over does to the data flow.

Every external request already enters through model binding into typed contract objects, and the rest of the system reads its inputs from those typed contracts. What the handler actually pulls from the raw request is narrower than the full input set, and the framework source pins it down: the origin comes solely from the request's Origin header, the relying-party id from the request Host unless ServerDomain is set in options, while the credential payload is already a plain parameter the caller supplies. That makes the coupling sharper.

The API mandates a whole request object purely to read the Origin header, a value the WebAuthn contract treats as a plain input and for which no parameter is offered, even though the credential beside it is already passed by value. The result is the same second, untyped channel: the headers carried in through a fabricated request, running parallel to the typed contracts the rest of the API uses.

This is the store coupling's orientation again, now on the input side rather than the storage side, and the same conclusion holds: the design reaches for the request because that is what it assumed, though the protocol does not need it. The documented escape hatch, reimplementing IPasskeyHandler, does not avoid this: the interface's own method signatures take an HttpContext, so an honest reimplementation means reimplementing WebAuthn end to end. Fido2NetLib does not have this binding: its ceremony methods take plain option and response objects and return plain results, with no HttpContext anywhere, which is precisely why it drops into a non-HTTP authentication pipeline unchanged.

The WebAuthn ceremony's contract is small and transport-agnostic: an origin, a relying-party id, challenge bytes, and the credential the client returns. None of that needs an HttpContext. For the Blazor and MVC target the API shipped for, the request is the input channel, so reading from it introduces no second path and the coupling is invisible. The split described above appears only where the consumer already has its own typed input boundary, which is precisely the clean-architecture, gRPC, and API-with-contracts case. For that audience the coupling is not a convenience, it is a design regression they have to work around. Making the ceremony transport-agnostic, accepting the few values it actually needs as plain arguments, is the change that would broaden its reach the most, and it is tractable because the protocol underneath does not depend on the request type.

Algorithm choice: EdDSA out of reach

The WebAuthn pubKeyCredParams array is contract: the W3C specification defines it as the key types and signature algorithms the Relying Party supports, "ordered from most preferred to least preferred", and the client and authenticator "make a best-effort to create a credential of the most preferred type possible". With a fabricated request context in place, the handler ran and emitted that array as ES256, PS256, ES384, PS384, PS512, RS256, ES512, RS384, RS512: nine algorithms, ES256 first, no EdDSA, a fixed order.

The only algorithm hook in the configuration object is IsAllowedAlgorithm, a filter predicate; it can remove entries but cannot reorder them or add one. We set it to admit EdDSA only, and the ceremony emitted an empty credential-parameters array, a registration that offers the authenticator nothing, because the filter can only subset the hardcoded list and EdDSA is not in it.

The framework's COSE enumerations do name EdDSA, Ed25519, and Ed448, but the names are inert. COSEAlgorithmIdentifier is internal and the curve enum is private; more to the point, the verification path behind those names is not implemented. An OKP-type key, which is what an Ed25519 credential is, routes into CredentialPublicKey's ECDSA parser, where the curve mapping throws NotSupportedException("OKP type curves not supported.") and the Verify method carries no OKP branch at all.

So the two gaps differ in kind: strength-first ordering of the nine algorithms that already work is a missing public knob, the cryptography there and only the priority control absent; EdDSA is a missing implementation, the identifiers present and the cryptography not. Through the documented options neither outcome is reachable. Fido2NetLib takes the pubKeyCredParams list the application supplies, in the application's order, EdDSA included.

Attestation: documented but yours to implement

This one MS named in their own Limitations section: "The implementation doesn't validate attestation statements by default." We did not have to run anything to confirm it: the API behaves exactly as the docs say. Attestation is the signed statement an authenticator returns at registration, vouching for which make and model created the credential, and verifying it is what makes authenticator-model policy possible: admitting only approved devices, or revoking every credential from a model later found vulnerable. The choice not to validate by default is reasonable for consumer flows, and the MS Learn warning is honest about why: "Attestation validation is complex and requires maintaining trust stores for authenticator certificates." The VerifyAttestationStatement delegate is the right hook, but it hands you the raw attestation object and the cryptographic work across the standard formats (packed, fido-u2f, android-key, android-safetynet, tpm, apple) is yours.

Fido2NetLib closes exactly this gap: it implements the full attestation statement verification across all of those formats out of the box, and Microsoft's own documentation points at it for the work, in its Administrative controls guidance. For an IdP that enforces authenticator-model policy the difference is concrete. With the built-in API you write the crypto pipeline; with Fido2NetLib you configure the model policy on top of one that already exists.

Read together, these are structural gaps, and they sit before the convenience of the built-in flow ever pays off: the migration stops at transport and policy. That is exactly the surface a more abstract ceremony API would open.

What would change our mind

To make us swap our current stack for the built-in API, the single change that matters most is making the ceremony abstract, and two things would do it.

First, let the ceremony take those inputs directly, rather than requiring an HttpContext. The convenience does not have to be lost: ship an optional adapter that extracts those values from an HttpContext for the Blazor and MVC audience that already has one, but keep the plain-argument entry point first-class, so the same values can equally arrive from ASP.NET model binding into typed contracts, a gRPC call, or a domain layer, and not only from a request object.

Second, let the handler depend on a narrow passkey-credential store directly, instead of reaching one only through a full UserManager and IUserStore. Storage technology is not the obstacle: the passkey store is just an interface, and an implementation can sit over any database and data-access stack such as EF or Dapper, behind a separate service, or anywhere else. The obstacle is the surface area the API forces around it, the user-management scaffolding and the request object, neither of which the protocol underneath needs. On the branch we backed the store with a non-EF adapter and it worked, yet the ceremony still required a constructed UserManager and a fabricated request to run. The shape that would unblock it is a ceremony that takes plain inputs and returns plain results, callable from a domain layer, an SPA backend, or a gRPC pipeline, not only an MVC or Blazor endpoint, with a turnkey integration beyond the Blazor template to broaden it further.

Next in impact is algorithm control. An explicit ordered priority list on IdentityPasskeyOptions is close at hand, since the nine algorithms already work and only the public knob to order them is missing. EdDSA is the larger ask, because it needs a real implementation: the COSE identifiers exist, the OKP verification does not. Beyond that, one addition would round out the IdP story: attestation statement verification across the standard WebAuthn formats as opt-in built-in behaviour, with the existing VerifyAttestationStatement delegate as the hook and a default crypto implementation underneath it.

These divide cleanly. The algorithm priority list, EdDSA support, and the attestation default are scope-expansion rather than architectural change, though not equal in cost: the priority list is the cheap one, since the framework already holds the pieces, while EdDSA and attestation each need a real crypto implementation underneath, not merely a public surface. The decoupling is the architectural change here, and the one that matters most, because it is what turns passkey support from a Blazor-and-Identity feature into a primitive any .NET authentication stack can build on.

This is a call to the Microsoft team responsible for passkeys, grounded in the .NET 10 source: one shift in lens. See .NET for the broad ecosystem it is, many products and stacks across many scenarios, the ones it covers today and the ones it is growing into, and design passkey support for that scope. A passkey ceremony decoupled from ASP.NET Core Identity, usable beyond the Identity-and-Blazor pairing, is the change we would most like to see, and the one that would let the .NET 10 implementation answer not just its first audience but every audience that has been asking. Nothing about the protocol or the implementation as it stands today seems to require this coupling, or to stand in the way of removing it: a decoupled ceremony would still serve the existing Identity scenarios as-is, and would also make passkeys a first-class primitive of the .NET stack, reusable in scenarios that today have to look elsewhere. We mean it as an opportunity, not a complaint.

As of the .NET 11 previews in mid-2026, nothing has moved in the direction this asks for. The active passkey work keeps deepening the Identity scenario: automatic authenticator display-name inference from the AAGUID, passkey endpoints on MapIdentityApi, conditional-create password upgrades, the WebAuthn signals API. None of it widens the ceremony beyond Identity. The original proposal shipped the Identity-scoped design and closed, and the open follow-ups extend that scope rather than its reach. EdDSA, a default attestation pipeline, and a request-free ceremony remain where this article found them, so the call stands.

Until it lands the guidance is concrete, not hedged: for a Blazor or MVC consumer app on Identity, the built-in API is the right choice today; for an OIDC Identity Provider, or any ceremony outside the request, Fido2NetLib remains the better tool. When it does, we will revisit this migration without hesitation; on today's evidence adoption is the outcome we expect, and the one we are rooting for.