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.
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.
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.sln2. 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.
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 in3. 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.
dotnet --info
dotnet dev-certs https --trust4. 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.
dotnet new blazor -au Individual -o PasskeyFirstIdentity
cd PasskeyFirstIdentity
dotnet watch5. 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.
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();dotnet ef migrations add AddPasskeySupport
dotnet ef database updateThe stored credential includes the credential ID, public key, signature counter, friendly name, and backup-eligibility state. The private key never enters your database.
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.
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";
});{
"Passkeys": {
"ServerDomain": "localhost"
}
}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.
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.
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();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.
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.
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();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();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;
}10. Manage Multiple Passkeys
Users need visibility and control. Show a friendly name, creation context, and backup status, and impose application-level limits.
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
});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:
- Collect and verify the email address.
- Issue a short-lived confirmation link.
- After confirmation, require enrollment of the first passkey.
- Create the authenticated session only after successful enrollment.
- Optionally allow a password to remain as a fallback—or allow removal after recovery requirements are met.
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);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.
static bool CanDeletePasskey(
int currentPasskeyCount,
bool hasPassword,
bool hasExternalLogin,
bool hasVerifiedRecoveryCodes)
{
if (currentPasskeyCount > 1)
{
return true;
}
return hasPassword || hasExternalLogin || hasVerifiedRecoveryCodes;
}13. Limits of the Built-in Support
| Capability | Built-in Identity passkeys | Notes |
|---|---|---|
| Registration and authentication | Yes | Core supported scenario |
| Username-less conditional UI | Yes | Requires discoverable credentials |
| Multiple credentials per user | Yes | Recommended for recovery |
| Attestation statement validation | No, by default | Add custom validation or a full WebAuthn library |
| AAGUID/MDS allow or deny policy | No, by default | Enterprise governance requires additional work |
| Prebuilt UI and endpoints | Blazor template only | Port manually for MVC/Razor Pages |
| General-purpose WebAuthn | No | Feature is scoped to 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:
- Copy the passkey endpoint mappings into your project.
- Preserve antiforgery validation and authentication requirements.
- Bring over the browser serialization/parsing code.
- Add your MVC or Razor Pages UI around those endpoints.
- Configure schema version 3 and run the migration.
- Test origin, RP ID, and reverse-proxy behavior in the real deployment topology.
WebApplication app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapControllers();
app.MapRazorPages();
app.MapPasskeyIdentityEndpoints();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.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);
});
}15. Test Without Hardware and Harden
Chrome DevTools and WebDriver support virtual authenticators, which are ideal for repeatable test cases.
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 changesSecurity 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.
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.
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.