If you've ever integrated FIDO2 or WebAuthn into an ASP.NET Core application, you've almost certainly reached for fido2-net-lib or a similar community library. There was simply no built-in alternative. You'd wire up attestation and assertion flows manually, manage credential storage yourself, and hope that the library's interpretation of the WebAuthn spec matched what browsers actually sent.
With .NET 10, that changes. ASP.NET Core Identity now includes first-class passkey support — registration, authentication, and credential management are part of the framework. The Blazor Web App template ships with passkey UI out of the box. For the most common authentication scenarios, you no longer need a third-party dependency.
But this isn't a general-purpose WebAuthn library, and Microsoft is explicit about that. Understanding where the built-in support ends is just as important as knowing what it provides.
What passkeys actually are
Passkeys are FIDO2 credentials that replace passwords with public key cryptography. When a user registers a passkey, their authenticator — Windows Hello, Touch ID, a hardware security key, or a password manager — generates a key pair. The private key stays on the device. The public key goes to your server. During sign-in, the authenticator proves it holds the private key by signing a server-generated challenge, and the server verifies the signature against the stored public key.
The private key never leaves the device. There's no shared secret to leak in a breach. The credential is bound to your domain, so it can't be phished. And most implementations sync across the user's devices, so they don't lose access when they get a new phone.
If you've been putting off passwordless authentication because the integration cost was too high, the barrier just dropped significantly.
The API surface
The passkey support lives in SignInManager<TUser> and UserManager<TUser>, which means it plugs into the same Identity infrastructure you're already using. There are six methods that matter:
Registration (attestation):
MakePasskeyCreationOptionsAsync— generates thePublicKeyCredentialCreationOptionsJSON that the browser's WebAuthn API needs to create a credentialPerformPasskeyAttestationAsync— validates the credential returned by the authenticator and returns aPasskeyAttestationResult
Authentication (assertion):
MakePasskeyRequestOptionsAsync— generates thePublicKeyCredentialRequestOptionsJSON for sign-inPerformPasskeyAssertionAsync— validates the signed assertion and returns aPasskeyAssertionResultwith the authenticated userPasskeySignInAsync— a convenience method that performs assertion and signs in the user in one call
Storage:
AddOrUpdatePasskeyAsynconUserManager— persists or updates a passkey in the database
The flow is straightforward: your server generates options, the browser passes them to the WebAuthn API, the authenticator does its thing, and the browser sends the result back for server-side validation.
Registering a passkey
Registration requires two endpoints: one to generate creation options, and one to receive the credential. Here's the options endpoint:
app.MapPost("/account/passkey-creation-options", async (
HttpContext context,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null) return Results.NotFound();
var userId = await userManager.GetUserIdAsync(user);
var userName = await userManager.GetUserNameAsync(user) ?? "User";
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
{
Id = userId,
Name = userName,
DisplayName = userName
});
return TypedResults.Content(optionsJson, contentType: "application/json");
}).RequireAuthorization();
The PasskeyUserEntity contains the user's ID, a Name (typically the email), and a DisplayName shown by the authenticator prompt. The method returns a JSON string conforming to the WebAuthn spec, and it also stores temporary state in an authentication cookie to correlate the response.
On the client side, the browser calls the WebAuthn API:
const optionsResponse = await fetch('/account/passkey-creation-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.create({ publicKey: options });
After the authenticator creates the credential, you send it back to the server for validation:
app.MapPost("/account/register-passkey", async (
HttpContext context,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null) return Results.NotFound();
var credentialJson = await new StreamReader(context.Request.Body).ReadToEndAsync();
var attestationResult = await signInManager.PerformPasskeyAttestationAsync(credentialJson);
if (!attestationResult.Succeeded)
return Results.BadRequest(attestationResult.Failure.Message);
var result = await userManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);
return result.Succeeded ? Results.Ok() : Results.BadRequest("Failed to store passkey");
}).RequireAuthorization();
PerformPasskeyAttestationAsync handles the hard parts: verifying the credential type, validating client data and origin, checking authenticator flags, and extracting the public key. If everything checks out, you get a PasskeyAttestationResult containing a UserPasskeyInfo object that you persist with AddOrUpdatePasskeyAsync.
Authenticating with a passkey
The sign-in flow mirrors registration. Generate options, let the browser get an assertion, validate it server-side:
app.MapPost("/account/passkey-request-options", async (
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
string? username) =>
{
var user = string.IsNullOrEmpty(username)
? null
: await userManager.FindByNameAsync(username);
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
return TypedResults.Content(optionsJson, contentType: "application/json");
});
Passing null for the user generates options suitable for discoverable credentials — the authenticator selects the right credential without the user typing a username first. This is the "just click sign in and pick your account" experience.
The simplest way to complete authentication is PasskeySignInAsync:
app.MapPost("/account/passkey-signin", async (
HttpContext context,
SignInManager<ApplicationUser> signInManager) =>
{
var credentialJson = await new StreamReader(context.Request.Body).ReadToEndAsync();
var result = await signInManager.PasskeySignInAsync(credentialJson);
return result.Succeeded
? Results.Ok()
: Results.Unauthorized();
});
This validates the assertion, signs in the user, and updates the signature counter in one call. If you need more control — say, to add custom claims or check additional conditions before establishing the session — use PerformPasskeyAssertionAsync directly and call AddOrUpdatePasskeyAsync afterwards to persist the updated counter.
// IMPORTANT
If you use PerformPasskeyAssertionAsync instead of PasskeySignInAsync, you must call AddOrUpdatePasskeyAsync with the returned passkey to update the signature counter. Skipping this weakens replay attack protection.
Configuring passkey behaviour
Options are set through IdentityPasskeyOptions:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ServerDomain = "myapp.example.com";
options.AuthenticatorTimeout = TimeSpan.FromMinutes(3);
options.ChallengeSize = 64;
options.UserVerificationRequirement = "required";
options.ResidentKeyRequirement = "preferred";
});
The key options:
- ServerDomain — the Relying Party ID. If unset, it's inferred from the request's
Hostheader. Set this explicitly in production to avoid credential-scoping attacks. - AuthenticatorTimeout — how long the browser waits for the authenticator to respond. Defaults to 5 minutes.
- ChallengeSize — the cryptographic challenge size in bytes. Defaults to 32.
- UserVerificationRequirement — whether the authenticator must verify the user's identity (biometric, PIN). Use
"required"for most apps. - ResidentKeyRequirement — whether credentials should be discoverable (enabling username-less sign-in).
"preferred"is a sensible default.
What the Blazor template gives you
If you create a new Blazor Web App targeting .NET 10 with Individual Accounts authentication, passkey support is already wired up. The template includes:
- A
PasskeySubmitBlazor component backed by a custom web component in JavaScript - Registration and login UI integrated into the account management pages
- Endpoints for creation options, request options, and credential submission
- A passkey management page where users can view, rename, and remove their passkeys
- Resource limits enforced at the application level (maximum passkeys per account, maximum display name length)
This is the fastest path to a working passkey implementation. If you're building a Blazor app, start here and customise rather than building from scratch.
// TIP
The template enforces sensible defaults like maximum passkeys per user and display name length limits. If you're building custom endpoints, implement similar limits to prevent database exhaustion attacks.
Custom attestation validation
By default, ASP.NET Core Identity does not validate attestation statements. This means it accepts credentials from any authenticator without verifying the authenticator's identity or properties. For consumer-facing apps, this is usually fine.
For enterprise environments where you need to restrict which authenticators are acceptable — or verify that a credential came from a FIPS-certified hardware key — you can plug in custom validation:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.VerifyAttestationStatement = async context =>
{
// Validate the attestation against your trust store
// Return true if the authenticator is acceptable
return true;
};
});
// WARNING
Attestation validation is complex and requires maintaining certificate trust stores for authenticator vendors. Only implement this if your security requirements demand it.
Similarly, you can customise origin validation if you need stricter subdomain controls:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ValidateOrigin = async context =>
{
var allowedOrigins = new[] { "https://myapp.example.com" };
return allowedOrigins.Contains(context.Origin);
};
});
Supported scenarios and their limits
The built-in support covers three scenarios:
- Adding passkeys to existing accounts — users with passwords can register passkeys as an additional method
- Passwordless account creation — users create accounts with a passkey instead of a password
- Passwordless sign-in — authenticating with just a passkey
What it deliberately does not support:
- Passkeys as a second factor — the implementation treats passkeys as a primary authentication method, not a 2FA mechanism
- Full WebAuthn compliance — advanced features like attestation format verification, authenticator selection policies, and extensions are out of scope
- Non-Identity scenarios — the APIs are tightly coupled to ASP.NET Core Identity; you can't use them as a standalone WebAuthn library
For anything beyond these scenarios, Microsoft explicitly recommends community libraries like fido2-net-lib.
Security considerations you shouldn't skip
Set ServerDomain explicitly
If you don't configure ServerDomain, it's inferred from the Host header. An attacker who can manipulate the host header could scope credentials to a domain they control. Always set it in production.
Plan for account recovery
If users can create accounts with only a passkey, losing their device means losing access. The Blazor template handles this by requiring a backup authentication method (password or external provider) during account creation. If you're building passwordless-only flows, implement recovery codes, email-based recovery, or require multiple passkey registrations.
Monitor the IsBackedUp flag
UserPasskeyInfo includes an IsBackedUp property that indicates whether the passkey is synced across devices. You can use this to prompt users with single-device credentials to register additional passkeys:
public async Task CheckPasskeyBackupStatus(
UserManager<ApplicationUser> userManager,
ApplicationUser user)
{
var passkeys = await userManager.GetPasskeysAsync(user);
var unbackedPasskeys = passkeys.Where(p => !p.IsBackedUp).ToList();
if (unbackedPasskeys.Count > 0 && passkeys.Count == 1)
{
// Prompt user to register additional passkeys
}
}
Enforce resource limits
Without limits, an attacker with a compromised session could register thousands of passkeys against an account. Cap the number of passkeys per user and validate display name lengths. The Blazor template does this — make sure your custom implementation does too.
Common pitfalls
Forgetting to update the signature counter. If you use PerformPasskeyAssertionAsync for more control over sign-in, you must call AddOrUpdatePasskeyAsync afterwards. The signature counter is the primary defence against cloned credentials, and failing to update it leaves a gap.
Mixing named and unnamed query filters — wait, wrong feature. More relevantly: mixing ServerDomain with host header inference. Pick one. If you configure ServerDomain as example.com but your app runs on app.example.com, passkeys registered on the app subdomain will work across all subdomains of example.com. This might be intentional for a multi-app domain, but it can also expose credentials to untrusted subdomains.
Assuming all browsers behave the same. Some password managers don't correctly implement PublicKeyCredential.toJSON(), which causes JSON.stringify to throw TypeError: Illegal invocation. The official docs include a workaround involving manual base64url serialisation of the credential fields. Test with multiple browsers and password managers.
Not requiring HTTPS. Passkey operations require a secure context. The implementation stores authentication state in encrypted cookies that could be intercepted over HTTP. Enforce HTTPS and configure HSTS.
Treating passkeys as 2FA. The built-in implementation is for primary authentication, not second-factor. If you need passkeys as a second factor alongside passwords, you'll need to build that layering yourself or use fido2-net-lib.
Summary
- ASP.NET Core 10 Identity ships with built-in passkey support for registration, authentication, and credential management
- The API centres on six methods across
SignInManagerandUserManager, following the standard WebAuthn attestation/assertion flow - The Blazor Web App template includes complete passkey UI — start there if you're building a new app
- Configure
ServerDomainexplicitly in production to prevent host header manipulation - The implementation covers the most common authentication scenarios but is not a general-purpose WebAuthn library
- Use
fido2-net-libor similar if you need attestation validation, 2FA passkeys, or advanced WebAuthn features - Plan account recovery carefully for passwordless-only flows — passkeys tied to a single device are a single point of failure
- Always update the signature counter after manual assertion validation to maintain replay protection