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):

Authentication (assertion):

Storage:

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:

Program.cs
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:

passkey-registration.js
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:

Program.cs
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:

Program.cs
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:

Program.cs
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:

Program.cs
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:

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:

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:

Program.cs
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:

Program.cs
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:

  1. Adding passkeys to existing accounts — users with passwords can register passkeys as an additional method
  2. Passwordless account creation — users create accounts with a passkey instead of a password
  3. Passwordless sign-in — authenticating with just a passkey

What it deliberately does not support:

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:

Services/PasskeySecurityService.cs
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