Passwords Are Optional

Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn

Build phishing-resistant registration and sign-in with the .NET 10 Identity passkey APIs, conditional UI, multi-device recovery, credential management, and production hardening.

API verification: Verified against .NET 10 and the RTM ASP.NET Core Identity passkey APIs. Preview-era names such as ConfigurePasskeyCreationOptionsAsync, AddPasskeys(), or SignInWithPasskeyAsync() are not used here.

1. What You Will Build

You will create PasskeyFirstIdentity, a .NET 10 Blazor Web App with Individual Accounts where users can create an account, enroll one or more passkeys, sign in through conditional UI, and recover when a device is lost.

Passkey registration

Generate WebAuthn creation options, invoke the authenticator, verify attestation, and persist the public credential.

Passwordless sign-in

Support username-first and username-less assertions, including browser autofill through conditional mediation.

Credential management

List, rename, and delete passkeys while protecting users from deleting their final recovery path.

Recovery-first design

Encourage multiple passkeys and combine them with verified email and recovery codes.

Project structure
PasskeyFirstIdentity/
├── src/PasskeyFirstIdentity.Web/
│   ├── Components/Account/Pages/
│   ├── Components/Account/Shared/PasskeySubmit.razor.js
│   ├── Identity/ApplicationDbContext.cs
│   ├── Identity/IdentityComponentsEndpointRouteBuilderExtensions.cs
│   ├── Migrations/
│   └── Program.cs
├── tests/PasskeyFirstIdentity.Tests/
├── Directory.Packages.props
└── PasskeyFirstIdentity.sln

2. Why Passwords Fail and What a Passkey Is

A password is a shared secret. The user knows it, the server verifies a derivative of it, and an attacker can attempt to steal or replay it. A passkey replaces that shared secret with a key pair:

  • The authenticator retains the private key.
  • The application stores the public key.
  • The browser signs a fresh challenge for the exact relying-party domain.
  • A phishing site cannot reuse the credential because the browser enforces origin and RP-ID binding.
Important: Passwordless does not remove account-security complexity. It moves the hardest question from “How do users remember a password?” to “How do users recover after losing every authenticator?”
Two WebAuthn ceremonies
REGISTRATION (attestation)
Browser -> Server: request creation options
Server -> Browser: challenge + RP + user options
Browser -> Authenticator: navigator.credentials.create()
Authenticator -> Browser: new public-key credential
Browser -> Server: credential JSON
Server: verify + store public key

AUTHENTICATION (assertion)
Browser -> Server: request assertion options
Server -> Browser: challenge + RP options
Browser -> Authenticator: navigator.credentials.get()
Authenticator -> Browser: signed assertion
Browser -> Server: assertion JSON
Server: verify signature + sign user in

3. Prerequisites

  • .NET 10 SDK and an IDE.
  • A modern browser with WebAuthn support.
  • HTTPS or localhost.
  • A platform authenticator such as Windows Hello, Touch ID, Face ID, Android screen lock, a security key, or a browser virtual authenticator.
  • A working email confirmation and recovery channel.
  • Basic familiarity with EF Core migrations.
Shell
dotnet --info

dotnet dev-certs https --trust

4. Create the Blazor Web App with Individual Accounts

The .NET 10 Blazor Web App template is the fastest starting point because it includes passkey endpoints and account-management UI when Individual Accounts is selected.

Shell
dotnet new blazor -au Individual -o PasskeyFirstIdentity
cd PasskeyFirstIdentity
dotnet watch
Template boundary: The built-in passkey UI and endpoints are included by the Blazor Web App template. MVC and Razor Pages applications require the porting work described later.

5. Prepare Identity Schema Version 3 and Migration

Existing Identity applications must opt into schema version 3. This adds the user-passkey entity and the AspNetUserPasskeys table.

CSHARP
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
    options.SignIn.RequireConfirmedAccount = true;
    options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
Shell
dotnet ef migrations add AddPasskeySupport
dotnet ef database update

The stored credential includes the credential ID, public key, signature counter, friendly name, and backup-eligibility state. The private key never enters your database.

Inspect the migration: Confirm that it creates AspNetUserPasskeys before deploying. A code-only upgrade without the migration leaves registration unable to persist credentials.

6. Configure IdentityPasskeyOptions and the RP ID

The RP ID is the security boundary that ties a passkey to your site. Configure it explicitly per environment instead of relying on an unvalidated host header.

CSHARP
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    options.ServerDomain =
        builder.Configuration["Passkeys:ServerDomain"];
    options.AuthenticatorTimeout = TimeSpan.FromMinutes(3);
    options.ChallengeSize = 32;
    options.UserVerificationRequirement = "preferred";
    options.ResidentKeyRequirement = "required";
});
JSON
{
  "Passkeys": {
    "ServerDomain": "localhost"
  }
}
Do not change the RP ID casually. A credential enrolled for one RP ID cannot authenticate for an unrelated domain. Use environment-specific configuration and validate host headers behind proxies.

7. Register a Passkey — Server-Side Attestation

The registration endpoint first creates WebAuthn options for a specific Identity user. The returned JSON is passed directly to the browser.

CSHARP
accountGroup.MapPost("/PasskeyCreationOptions", async (
    ClaimsPrincipal principal,
    UserManager<ApplicationUser> userManager,
    SignInManager<ApplicationUser> signInManager) =>
{
    ApplicationUser user = await userManager.GetUserAsync(principal)
        ?? throw new InvalidOperationException("Authenticated user required.");

    string userId = await userManager.GetUserIdAsync(user);
    string userName = await userManager.GetUserNameAsync(user)
        ?? throw new InvalidOperationException("Username required.");

    var userEntity = new PasskeyUserEntity
    {
        Id = userId,
        Name = userName,
        DisplayName = userName
    };

    string optionsJson =
        await signInManager.MakePasskeyCreationOptionsAsync(userEntity);

    return TypedResults.Content(optionsJson, "application/json");
})
.RequireAuthorization()
.ValidateAntiforgery();

The second endpoint verifies the credential returned by navigator.credentials.create(), then stores the verified passkey.

CSHARP
accountGroup.MapPost("/RegisterPasskey", async (
    HttpRequest request,
    ClaimsPrincipal principal,
    UserManager<ApplicationUser> userManager,
    SignInManager<ApplicationUser> signInManager) =>
{
    ApplicationUser user = await userManager.GetUserAsync(principal)
        ?? throw new InvalidOperationException("Authenticated user required.");

    using var reader = new StreamReader(request.Body);
    string credentialJson = await reader.ReadToEndAsync();

    PasskeyAttestationResult attestation =
        await signInManager.PerformPasskeyAttestationAsync(credentialJson);

    if (!attestation.Succeeded)
    {
        return Results.BadRequest(new
        {
            error = attestation.Failure?.Message ?? "Passkey verification failed."
        });
    }

    IdentityResult stored = await userManager.AddOrUpdatePasskeyAsync(
        user,
        attestation.Passkey);

    return stored.Succeeded
        ? Results.NoContent()
        : Results.ValidationProblem(stored.Errors.ToDictionary(
            error => error.Code,
            error => new[] { error.Description }));
})
.RequireAuthorization()
.ValidateAntiforgery();
RTM naming: Use MakePasskeyCreationOptionsAsync. The preview name ConfigurePasskeyCreationOptionsAsync appears in older articles.

8. Register a Passkey — Browser-Side WebAuthn

Modern browsers provide parsing helpers for WebAuthn JSON. The browser creates the credential, while the authenticator generates and protects the private key.

JAVASCRIPT
export async function createPasskey(antiforgeryToken) {
  const headers = {
    "Content-Type": "application/json",
    "RequestVerificationToken": antiforgeryToken
  };

  const optionsResponse = await fetch(
    "/Account/PasskeyCreationOptions",
    { method: "POST", headers });

  if (!optionsResponse.ok) {
    throw new Error("Could not create passkey options.");
  }

  const optionsJson = await optionsResponse.json();
  const publicKey =
    PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);

  const credential = await navigator.credentials.create({ publicKey });
  if (!credential) {
    throw new Error("The authenticator returned no credential.");
  }

  const registerResponse = await fetch("/Account/RegisterPasskey", {
    method: "POST",
    headers,
    body: JSON.stringify(credential)
  });

  if (!registerResponse.ok) {
    throw new Error(await registerResponse.text());
  }
}

Keep the browser payload in its native WebAuthn JSON shape. Do not invent parallel credential DTOs that can silently omit security-critical fields.

9. Sign In with a Passkey and Conditional UI

Authentication can be username-first or username-less. Passing null to MakePasskeyRequestOptionsAsync supports discoverable credentials and conditional UI.

CSHARP
accountGroup.MapPost("/PasskeyRequestOptions", async (
    string? username,
    UserManager<ApplicationUser> userManager,
    SignInManager<ApplicationUser> signInManager) =>
{
    ApplicationUser? user = string.IsNullOrWhiteSpace(username)
        ? null
        : await userManager.FindByNameAsync(username);

    string optionsJson =
        await signInManager.MakePasskeyRequestOptionsAsync(user);

    return TypedResults.Content(optionsJson, "application/json");
})
.ValidateAntiforgery();
CSHARP
accountGroup.MapPost("/PasskeySignIn", async (
    HttpRequest request,
    SignInManager<ApplicationUser> signInManager) =>
{
    using var reader = new StreamReader(request.Body);
    string credentialJson = await reader.ReadToEndAsync();

    SignInResult result =
        await signInManager.PasskeySignInAsync(credentialJson);

    return result.Succeeded
        ? Results.NoContent()
        : Results.Unauthorized();
})
.ValidateAntiforgery();
JAVASCRIPT
export async function signInWithPasskey(
  username,
  antiforgeryToken,
  conditional = false) {

  const headers = {
    "Content-Type": "application/json",
    "RequestVerificationToken": antiforgeryToken
  };

  const query = username
    ? `?username=${encodeURIComponent(username)}`
    : "";

  const optionsResponse = await fetch(
    `/Account/PasskeyRequestOptions${query}`,
    { method: "POST", headers });

  const optionsJson = await optionsResponse.json();
  const publicKey =
    PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);

  const credential = await navigator.credentials.get({
    publicKey,
    mediation: conditional ? "conditional" : "optional"
  });

  if (!credential) return false;

  const response = await fetch("/Account/PasskeySignIn", {
    method: "POST",
    headers,
    body: JSON.stringify(credential)
  });

  return response.ok;
}
The payoff: With a discoverable credential, the browser can surface the user's passkey in the username field. The user selects it, verifies with biometrics or a PIN, and signs in without typing a username or password.

10. Manage Multiple Passkeys

Users need visibility and control. Show a friendly name, creation context, and backup status, and impose application-level limits.

CSHARP
ApplicationUser user = await userManager.GetUserAsync(User)
    ?? throw new InvalidOperationException("Authenticated user required.");

IList<UserPasskeyInfo> passkeys =
    await userManager.GetPasskeysAsync(user);

var view = passkeys.Select(passkey => new
{
    passkey.Name,
    passkey.CredentialId,
    passkey.IsBackupEligible,
    passkey.IsBackedUp
});
CSHARP
UserPasskeyInfo? passkey =
    await userManager.FindPasskeyAsync(user, credentialId);

if (passkey is null)
{
    return Results.NotFound();
}

passkey.Name = friendlyName.Trim();
IdentityResult result =
    await userManager.AddOrUpdatePasskeyAsync(user, passkey);
  • Limit the number of passkeys per account.
  • Limit friendly-name length.
  • Require reauthentication before deletion.
  • Block deletion of the last factor unless a verified recovery method remains.

11. Go Passwordless: Bootstrap and Optional Password Removal

A passkey-first account still needs a trustworthy bootstrap. A practical flow is:

  1. Collect and verify the email address.
  2. Issue a short-lived confirmation link.
  3. After confirmation, require enrollment of the first passkey.
  4. Create the authenticated session only after successful enrollment.
  5. Optionally allow a password to remain as a fallback—or allow removal after recovery requirements are met.
CSHARP
IList<UserPasskeyInfo> passkeys =
    await userManager.GetPasskeysAsync(user);

bool hasVerifiedRecoveryEmail =
    await userManager.IsEmailConfirmedAsync(user);

if (passkeys.Count == 0 || !hasVerifiedRecoveryEmail)
{
    return Results.Problem(
        statusCode: StatusCodes.Status409Conflict,
        detail: "Add a passkey and verify a recovery email before removing the password.");
}

IdentityResult result = await userManager.RemovePasswordAsync(user);
Design decision: Keeping a password provides familiar recovery but reintroduces a phishable factor. Removing it improves the primary path but raises the importance of email security, backup credentials, and support procedures.

12. Account Recovery and Device Loss

Recovery is not an appendix; it is part of the authentication system. Use a layered default:

  • Encourage at least two passkeys.
  • Prefer at least one backed-up or cross-device credential.
  • Require a verified email address.
  • Offer one-time recovery codes stored securely by the user.
  • Notify the user whenever a passkey is added, renamed, or removed.
  • Use step-up verification before changing recovery settings.
CSHARP
static bool CanDeletePasskey(
    int currentPasskeyCount,
    bool hasPassword,
    bool hasExternalLogin,
    bool hasVerifiedRecoveryCodes)
{
    if (currentPasskeyCount > 1)
    {
        return true;
    }

    return hasPassword || hasExternalLogin || hasVerifiedRecoveryCodes;
}
Never let a user remove the final usable factor. A fully passwordless account without another passkey or verified recovery route is one lost device away from permanent lockout.

13. Limits of the Built-in Support

CapabilityBuilt-in Identity passkeysNotes
Registration and authenticationYesCore supported scenario
Username-less conditional UIYesRequires discoverable credentials
Multiple credentials per userYesRecommended for recovery
Attestation statement validationNo, by defaultAdd custom validation or a full WebAuthn library
AAGUID/MDS allow or deny policyNo, by defaultEnterprise governance requires additional work
Prebuilt UI and endpointsBlazor template onlyPort manually for MVC/Razor Pages
General-purpose WebAuthnNoFeature is scoped to Identity authentication
Use Fido2-net-lib when: the business must validate attestation statements, enforce authenticator models, inspect metadata service records, or implement WebAuthn outside ordinary ASP.NET Core Identity authentication.

14. Port the Endpoints to MVC or Razor Pages

For a non-Blazor application, treat the generated Blazor Identity endpoint extension as a reference implementation:

  1. Copy the passkey endpoint mappings into your project.
  2. Preserve antiforgery validation and authentication requirements.
  3. Bring over the browser serialization/parsing code.
  4. Add your MVC or Razor Pages UI around those endpoints.
  5. Configure schema version 3 and run the migration.
  6. Test origin, RP ID, and reverse-proxy behavior in the real deployment topology.
CSHARP
WebApplication app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();

app.MapControllers();
app.MapRazorPages();
app.MapPasskeyIdentityEndpoints();
Define the antiforgery filter. ValidateAntiforgery() is not built in for minimal APIs; add this small endpoint-filter helper so the endpoints above compile and enforce CSRF protection on JSON POSTs.
CSHARP
using Microsoft.AspNetCore.Antiforgery;

internal static class AntiforgeryEndpointExtensions
{
    public static TBuilder ValidateAntiforgery<TBuilder>(this TBuilder builder)
        where TBuilder : IEndpointConventionBuilder =>
        builder.AddEndpointFilter(async (context, next) =>
        {
            var antiforgery = context.HttpContext.RequestServices
                .GetRequiredService<IAntiforgery>();
            try
            {
                await antiforgery.ValidateRequestAsync(context.HttpContext);
            }
            catch (AntiforgeryValidationException)
            {
                return Results.BadRequest("Antiforgery validation failed.");
            }
            return await next(context);
        });
}
Do not copy only the UI. The ceremony depends on endpoint behavior, temporary cookie state, antiforgery, schema support, and browser JSON handling working together.

15. Test Without Hardware and Harden

Chrome DevTools and WebDriver support virtual authenticators, which are ideal for repeatable test cases.

Test matrix
Registration
- enroll a discoverable credential
- duplicate registration behavior
- invalid challenge and origin
- maximum credential count

Authentication
- username-first assertion
- username-less conditional UI
- invalid signature
- stale or replayed assertion
- cancellation and timeout

Recovery
- delete one of multiple passkeys
- block deletion of the final factor
- recover with code or verified email
- notify user after credential changes

Security hardening

  • Use HTTPS and HSTS in production.
  • Set and validate the RP ID explicitly.
  • Validate host headers when behind a proxy.
  • Apply antiforgery to state-changing browser endpoints.
  • Rate-limit creation-options, request-options, registration, and sign-in routes.
  • Cap passkeys per user and name length.
  • Avoid account enumeration in username-first flows.
  • Log security events without logging credential JSON.
  • Protect email and recovery channels as authentication factors.
CSHARP
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("passkeys", limiter =>
    {
        limiter.PermitLimit = 10;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
    });
});

accountGroup.RequireRateLimiting("passkeys");

16. Production Readiness Checklist

  • Identity schema version 3 is enabled and migrated.
  • RP ID is explicit and correct in every environment.
  • HTTPS, HSTS, host validation, and proxy forwarding are configured.
  • Registration and sign-in endpoints validate antiforgery.
  • Conditional UI is tested across supported browsers.
  • Users can register, rename, and remove multiple credentials.
  • The final-factor deletion guard is enforced server-side.
  • Email recovery and recovery codes are tested end to end.
  • Credential events generate audit records and user notifications.
  • Rate limits and resource limits are configured.
  • Attestation-policy requirements are documented honestly.
  • Virtual-authenticator tests run in CI.

17. What to Build Next

This tutorial establishes the passkey-first foundation. Natural follow-ups include:

  • Enterprise authenticator policy: add attestation validation and AAGUID/MDS controls.
  • Step-up authentication: require a passkey before high-risk account changes.
  • IdentityServer integration: port the same ceremonies into a centralized identity provider.
  • Security-key administration: build organization-managed credential enrollment and revocation.
  • Account-recovery drills: test support procedures before a real user loses every device.
Central lesson: Passkeys remove shared secrets from the primary sign-in path, but a trustworthy passwordless application also needs deliberate recovery, domain configuration, credential lifecycle controls, and honest limits.

Frequently asked questions

Does ASP.NET Core Identity validate authenticator attestation?

Not by default. The built-in feature validates the authentication ceremony but is deliberately not a complete WebAuthn governance stack.

Can a user sign in without entering an email address?

Yes, if the credential is discoverable. Generate request options without a specific user and use conditional UI or username-less authentication.

Should every user register two passkeys?

It is a strong recovery recommendation, especially for accounts that have no password. A second device or hardware key substantially reduces lockout risk.

Can the RP ID be localhost in production?

No. Use localhost only for local development. Production must use the stable registrable domain under which credentials will be created and used.

Primary references

Back to Tutorials