Adding Passkeys to ASP.NET Core Identity (with 2FA Fallback)
How to add passkey (WebAuthn) sign-in to ASP.NET Core Identity in .NET 10, keep TOTP 2FA as the fallback, and roll passkeys out to an existing user base.
To add passkeys to ASP.NET Core Identity, you register WebAuthn credentials against the same user store Identity already manages, treat the passkey as a first-class sign-in method (not a second factor), and keep TOTP two-factor authentication as the fallback and recovery path. As of .NET 10, this is built in: SignInManager<TUser> exposes the full WebAuthn ceremony, the EF Core store persists credentials, and the Blazor Web App template with Individual accounts scaffolds passkey management for you. What’s left for you to design is the part the framework can’t: the fallback strategy and the rollout.
I shipped exactly this combination on a multi-language task and activity platform — .NET 10, Blazor Interactive Server, Clean Architecture — where enterprise users are gated by role and department and sign in with ASP.NET Identity using passkeys backed by 2FA. You can read the case study here. This article is the distilled version of that setup.
Passwords vs TOTP vs passkeys: what each one actually stops
Before touching code, be clear on what problem each mechanism solves, because it dictates your fallback design.
| Mechanism | Server stores | Stops credential stuffing | Stops real-time phishing |
|---|---|---|---|
| Password | A hash of a shared secret | No | No |
| Password + TOTP | Hash + a TOTP seed | Yes | No |
| Passkey | A public key only | Yes | Yes |
A password is a shared secret: whatever the user types can be replayed anywhere. TOTP fixes credential stuffing — a leaked password alone is no longer enough — but it does not fix phishing. A proxy site that relays the victim’s password and their six-digit code within the 30-second window walks straight through TOTP. This attack is commoditized; off-the-shelf phishing kits do it.
A passkey is different in kind. It’s a public-key credential: the private key never leaves the user’s authenticator, and the browser only signs challenges for the domain (the Relying Party ID) the credential was registered against. A phishing proxy on a lookalike domain gets nothing, because the browser refuses to produce an assertion for the wrong origin. With user verification required (biometric or device PIN), a passkey is effectively multi-factor on its own — possession of the device plus verification of the person.
The design consequence: your fallback path is your real security boundary. If a passkey user can fall back to a bare password, attackers will simply phish the fallback. That’s why the pattern I recommend is passkey as primary, password plus TOTP as fallback — never password alone once a passkey exists.
What .NET 10 gives you out of the box
Scaffold a new project and you get passkeys without writing an endpoint:
dotnet new blazor -au Individual -o PasskeyDemo
The Identity UI in the template includes passkey registration and sign-in. Under the hood it’s driven by APIs you can also call directly, which is what you’ll do in an existing app. The knobs live in IdentityPasskeyOptions:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
// The Relying Party ID — your registrable domain.
// Credentials are bound to this; changing it orphans existing passkeys.
options.ServerDomain = "example.com";
options.AuthenticatorTimeout = TimeSpan.FromMinutes(3);
options.UserVerificationRequirement = "required";
options.ResidentKeyRequirement = "preferred";
});
Set UserVerificationRequirement to required if you want the passkey to stand alone as MFA — that’s the setting that forces the biometric/PIN check on the device. ResidentKeyRequirement = "preferred" asks authenticators to create discoverable credentials, which is what enables username-less sign-in later.
One note for Blazor apps: the WebAuthn ceremony always happens in the browser via navigator.credentials, regardless of render mode. On Interactive Server — the render mode I use for most line-of-business apps, for reasons I covered in Blazor Server vs WebAssembly vs Hybrid — that means a small JS interop boundary: the server produces options JSON, the browser runs the ceremony, and the resulting credential JSON travels back. It’s the same “server owns state, browser does browser things” split that makes SignalR features like live leaderboards a natural fit on Server.
The registration ceremony
Registration is a two-request dance: the server issues creation options (including a one-time challenge), the browser asks the authenticator to mint a key pair, and the server verifies the attestation and stores the public key. SignInManager manages the temporary challenge state for you between the two requests.
The user must already be signed in — a passkey is added from account settings, attached to an existing account:
app.MapPost("/Account/PasskeyCreationOptions", async (
HttpContext context,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.Unauthorized();
}
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 browser side parses those options and runs the ceremony:
async function registerPasskey() {
const optionsResponse = await fetch('/Account/PasskeyCreationOptions', {
method: 'POST'
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.create({ publicKey: options });
await fetch('/Account/PasskeyRegistration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialJson: JSON.stringify(credential) })
});
}
Back on the server, verify the attestation and persist:
public sealed record PasskeyRegistrationRequest(string CredentialJson);
app.MapPost("/Account/PasskeyRegistration", async (
PasskeyRegistrationRequest request,
HttpContext context,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.Unauthorized();
}
var attestationResult =
await signInManager.PerformPasskeyAttestationAsync(request.CredentialJson);
if (!attestationResult.Succeeded)
{
return Results.BadRequest(attestationResult.Failure.Message);
}
var addResult =
await userManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);
return addResult.Succeeded
? Results.Ok()
: Results.BadRequest("Failed to store the passkey.");
}).RequireAuthorization();
Let users name their passkeys (“Work laptop”, “Phone”) and show a list with a delete button. People manage passkeys like they manage sessions — visibility builds trust.
The sign-in ceremony
Login mirrors registration: request options, run the ceremony in the browser, verify the assertion. Passing a null user to MakePasskeyRequestOptionsAsync enables discoverable-credential sign-in — the user picks an account from the browser’s own UI without typing a username:
app.MapPost("/Account/PasskeyRequestOptions", 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");
});
public sealed record PasskeyLoginRequest(string CredentialJson);
app.MapPost("/Account/PasskeyLogin", async (
PasskeyLoginRequest request,
SignInManager<ApplicationUser> signInManager) =>
{
var result = await signInManager.PasskeySignInAsync(request.CredentialJson);
return result.Succeeded ? Results.Ok() : Results.Unauthorized();
});
PasskeySignInAsync verifies the assertion against the stored public key, updates the credential’s bookkeeping, and issues the Identity auth cookie — the same cookie the rest of your authorization pipeline already consumes. Role checks, policies, and claims all keep working untouched, which is precisely why registering passkeys inside Identity beats bolting a separate WebAuthn service onto the side. The full flow, including conditional UI so passkeys surface in the browser’s username autofill, is documented in the ASP.NET Core passkey docs.
Keep TOTP as the fallback — deliberately
Passkeys fail in mundane ways: a lost phone, a corporate browser profile that blocks the platform authenticator, a user who registered on one ecosystem and is now standing at a machine in another. Your fallback determines whether those users are locked out or merely inconvenienced.
The structure I use:
- Primary: passkey.
- Fallback: password + TOTP, using Identity’s standard two-factor flow.
- Last resort: single-use recovery codes, generated at 2FA enrollment.
The fallback path is stock Identity:
var result = await signInManager.TwoFactorAuthenticatorSignInAsync(
code, isPersistent: false, rememberClient: false);
// Last resort, when the authenticator app is gone too:
var recovery = await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
For TOTP enrollment, generate the shared key with UserManager and render it as a QR code so users scan it straight into their authenticator app instead of typing a Base32 string. That enrollment UX matters more than it looks: on a Belgian contractor-matching platform I built, 2FA enrollment is QR-coded for exactly this reason — details in the case study.
Two recovery rules I treat as non-negotiable. First, email password reset must not silently neutralize the passkey — if reset alone regains full access, your account security equals your inbox security. Require the second factor (or a recovery code) during reset for any user who has 2FA enabled. Second, resist helpdesk-driven “just remove their 2FA” resets; social-engineering the support desk is now the standard bypass for strong auth. The general guidance in the ASP.NET Core security docs is a good baseline here.
Rolling passkeys out to an existing user base
Flipping the switch is the easy part; adoption is the project. What works:
- Opt-in first. Ship passkey creation on the account security page before touching the login page. Early adopters shake out device-ecosystem issues at zero risk.
- Nudge after success, not before. Prompt “add a passkey for faster sign-in next time” right after a successful password + TOTP login. The user has just felt the friction you’re removing. Never gate login behind the pitch.
- Don’t delete passwords. Removing the password path org-wide is a phase-three decision driven by adoption data, not a launch-day one.
- Expect ecosystem lock-in questions. A passkey synced to one platform’s keychain doesn’t follow the user to another OS. Encourage registering a second passkey per account, and keep TOTP alive for exactly this case.
- Watch three numbers: percentage of active users with at least one passkey, percentage of sign-ins via passkey, and fallback usage per passkey user. Rising fallback usage means something is broken on a device class you’re not testing.
On the enterprise platform I mentioned, the audience was staff gated by role and department — a population where “faster sign-in with no codes to type” is an easy pitch. Consumer audiences move slower; plan the nudge campaign accordingly.
If you’re weighing this kind of authentication work for your own product — Identity, 2FA, passkeys, or migrating off a legacy auth setup — that’s a large part of what I build for clients.
Bottom line: passkeys drop into ASP.NET Core Identity in .NET 10 with a handful of endpoints and one JS interop file. Spend your design budget where the framework can’t help you — a fallback chain that never degrades to a bare password, a reset flow that doesn’t bypass everything, and a rollout that meets users where they already are.