Passkeys 2: Passkey Registration and Passwordless Sign-in with ASP.NET Core Identity 10 and an Angular Client
ASP.NET Core Identity 10 ships passkey registration and passwordless sign-in through SignInManager's MakePasskeyCreationOptionsAsync, PerformPasskeyAttestationAsync and PasskeySignInAsync; but with an empty ServerDomain the RP ID comes from the Host header, and a passkey sign-in writes pwd as amr. We build the Angular client, conditional UI and an end-to-end test with headless Chrome's virtual authenticator.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14, Angular 22.2 and Chrome 156 (headless, virtual authenticator).
Guess First
In the previous post we saw that a passkey is phishing-resistant and multi-factor on its own. In Bookstore, Ada signs in without a password, with her passkey only. Then the /me endpoint returns the session's amr claim. What does it say: mfa, hwk, swk?
sign out -> signed-out
GET /me -> 401
passkey login -> signed-in (passkey)
GET /me -> {"name":"ada@bookstore.test","amr":["pwd"]}
pwd. ASP.NET Core Identity 10's PasskeySignInAsync opens the session along the same path as a password sign-in and writes the same value. A step-up policy that checks amr == "mfa", as in post 5, sees a user who signed in with a passkey as if they had used a password. This is one of the deliberate limits of Identity's passkey support, and in this post we look at all of them one by one. First we build registration and passwordless sign-in with Identity 10's passkey APIs (Application Programming Interfaces); because Identity's ready-made passkey UI (User Interface) exists only in the Blazor template, we write the Angular side ourselves. Finally we run everything end to end with headless Chrome's virtual authenticator.
IdentityPasskeyOptions and ServerDomain
Passkey support rests on the AspNetUserPasskeys table that came with IdentitySchemaVersions.Version3 in post 3, and on IdentityPasskeyOptions. The defaults:
AuthenticatorTimeout=00:05:00 ChallengeSize=32 ServerDomain=(null)
UserVerificationRequirement=required ResidentKeyRequirement=preferred AttestationConveyancePreference=(null)
ServerDomain is the RP (Relying Party) ID and its default is null: in that case Identity derives the RP ID from the request's Host header. The Microsoft documentation spells this out as a security note; in the lab I saw what it means. With ServerDomain empty, I requested creation options with a signed-in session and a forged Host header:
$ curl -s -X POST http://localhost:19170/account/passkeys/creation-options -H "Host: evil.example" -H "Cookie: bookstore.auth=..."
{"rp":{"name":"evil.example","id":"evil.example"},"user":{"id":"NDQ3ZjY0NmItZmI0Mi00YWFiLTk3NjYtMTZlMjcwYzk3ZTBk","name"...
The RP ID is now a domain chosen by the attacker. If there is a reverse proxy in front that doesn't validate the Host header, or the application answers for several host names, this leads to credentials being scoped to the wrong domain. In Bookstore we take the RP ID from configuration:
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
// RP ID: never inferred from the Host header
options.ServerDomain = builder.Configuration["Passkeys:ServerDomain"];
options.UserVerificationRequirement = "required";
options.ResidentKeyRequirement = "required";
options.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
});
The same request returned {"rp":{"name":"localhost","id":"localhost"}... with ServerDomain=localhost. Narrowing the host names Kestrel accepts with AllowedHosts is also a good second layer. I set ResidentKeyRequirement to required because signing in without asking for a user name needs the credential to be discoverable. UserVerificationRequirement is already required; lowering it reduces a passkey to a single factor.
Registration Endpoints
Registration takes two requests while the user is signed in. The first produces the creation options:
passkeys.MapPost("/creation-options", async (HttpContext context, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
BookstoreUser user = (await users.GetUserAsync(context.User))!;
string optionsJson = await signIn.MakePasskeyCreationOptionsAsync(new()
{
Id = await users.GetUserIdAsync(user),
Name = (await users.GetUserNameAsync(user))!,
DisplayName = (await users.GetUserNameAsync(user))!
});
await CaptureAsync("creation-options.json", optionsJson);
return TypedResults.Content(optionsJson, "application/json");
}).RequireAuthorization();
CaptureAsync is lab-only; it writes the raw WebAuthn (Web Authentication) data to a file so we could decode it in the previous post. What really matters is the invisible part: MakePasskeyCreationOptionsAsync has to put the challenge and the user the registration belongs to (the attestation state) somewhere. I saw it in the response:
HTTP/1.1 200 OK
Set-Cookie: Identity.TwoFactorUserId=CfDJ8EINq_wm4TFCv7guzy6SHCezncBJP83CF5H7tWjrQKM5XgBJnnplkwo-mMxGCuA2ux3OUDNCvSjm87S...
SignInManager writes the state into the Identity.TwoFactorUserId scheme of the 2FA (Two-Factor Authentication) flow, a cookie protected with Data Protection, and deletes it as soon as it reads it. That meets the three requirements the documentation stresses: the client cannot read or modify the state (integrity), the state is bound to this browser session (ownership), and it is used once. If you call IPasskeyHandler<TUser> directly, that responsibility moves to you; putting the state in a hidden field or the request body opens the door for an attacker to add their own authenticator to another account. So use the SignInManager methods.
The second request verifies and stores the credential the browser produced:
passkeys.MapPost("/", async (PasskeyCredentialRequest request, HttpContext context, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
BookstoreUser user = (await users.GetUserAsync(context.User))!;
if ((await users.GetPasskeysAsync(user)).Count >= 10)
{
return Results.BadRequest(new[] { "TooManyPasskeys" });
}
await CaptureAsync("attestation.json", request.CredentialJson);
PasskeyAttestationResult attestation = await signIn.PerformPasskeyAttestationAsync(request.CredentialJson);
if (!attestation.Succeeded)
{
return Results.BadRequest(new[] { attestation.Failure.Message });
}
attestation.Passkey.Name = request.Name is { Length: > 0 and <= 50 } name ? name : "Passkey";
IdentityResult saved = await users.AddOrUpdatePasskeyAsync(user, attestation.Passkey);
return saved.Succeeded ? TypedResults.NoContent() : Results.BadRequest(saved.Errors.Select(e => e.Code));
}).RequireAuthorization();
The limits on passkeys per user and name length are the documentation's "resource limits" recommendation; otherwise someone could add thousands of passkeys to their account and bloat the table. PerformPasskeyAttestationAsync checks the type, challenge, origin, RP ID hash, flags and public key; it does not verify the attestation statement by default.
Sign-in Endpoints and Replay
passkeys.MapPost("/request-options", async (PasskeyRequestOptionsRequest request, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
// Without an e-mail the browser offers discoverable credentials (conditional UI)
BookstoreUser? user = request.Email is null ? null : await users.FindByEmailAsync(request.Email);
string optionsJson = await signIn.MakePasskeyRequestOptionsAsync(user);
await CaptureAsync("request-options.json", optionsJson);
return TypedResults.Content(optionsJson, "application/json");
});
passkeys.MapPost("/sign-in", async (PasskeyCredentialRequest request, SignInManager<BookstoreUser> signIn) =>
{
await CaptureAsync("assertion.json", request.CredentialJson);
try
{
SignInResult result = await signIn.PasskeySignInAsync(request.CredentialJson);
return result.Succeeded ? TypedResults.NoContent() : Results.Unauthorized();
}
catch (InvalidOperationException)
{
// No request options were issued in this browser (missing or expired state)
return Results.BadRequest(new[] { "NoPasskeyCeremonyUnderway" });
}
});
The try/catch wasn't in the first version of the lab. When I sent a captured assertion without the state cookie, the endpoint returned 500 and the log said: System.InvalidOperationException: No passkey assertion is underway. Make sure to call 'SignInManager.MakePasskeyRequestOptionsAsync()' to initiate a passkey assertion. This is not a programming error but an ordinary situation on the user's side (the tab stayed open too long, the cookie was cleared), so I turn it into a 400. The two forms of the replay attempt:
== replay without state
["NoPasskeyCeremonyUnderway"] 400
== replay with fresh request-options
HTTP/1.1 401 Unauthorized
Even with a fresh challenge, the sign-in is rejected because the challenge inside the old assertion is different. If an e-mail is passed to request-options, Identity puts that user's credential ids into allowCredentials; that leaks whether an e-mail belongs to an account with passkeys. Bookstore's default flow is without an e-mail, with discoverable credentials.
Angular: PasskeyService
Thanks to WebAuthn Level 3's JSON (JavaScript Object Notation) helpers there is no need to write Base64Url conversions by hand. The JSON produced by the server becomes the structure the browser expects through parseCreationOptionsFromJSON, and the credential goes back with JSON.stringify (through the credential's toJSON method):
@Injectable({ providedIn: 'root' })
export class PasskeyService {
private readonly http = inject(HttpClient);
async register(name: string): Promise<void> {
const optionsJson = await firstValueFrom(
this.http.post<PublicKeyCredentialCreationOptionsJSON>('/account/passkeys/creation-options', {}),
);
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.create({ publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was created');
}
await firstValueFrom(
this.http.post('/account/passkeys', { credentialJson: JSON.stringify(credential), name }),
);
}
async signIn(email: string | null, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<void> {
const optionsJson = await firstValueFrom(
this.http.post<PublicKeyCredentialRequestOptionsJSON>('/account/passkeys/request-options', { email }),
);
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.get({ publicKey, mediation, signal });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was selected');
}
await firstValueFrom(
this.http.post('/account/passkeys/sign-in', { credentialJson: JSON.stringify(credential) }),
);
}
async conditionalUiAvailable(): Promise<boolean> {
return typeof PublicKeyCredential !== 'undefined'
&& typeof PublicKeyCredential.isConditionalMediationAvailable === 'function'
&& await PublicKeyCredential.isConditionalMediationAvailable();
}
}
The types (PublicKeyCredentialCreationOptionsJSON and the others) exist in TypeScript 6's DOM (Document Object Model) definitions; no extra package was needed. The Microsoft documentation notes that some password managers implement toJSON incorrectly and JSON.stringify can throw "Illegal invocation"; if you see that error, add a fallback that serializes the credential field by field.
There is also the cookie side: because the creation-options endpoint returns TypedResults.Content, .NET 10's "don't redirect API endpoints" rule didn't cover it and an unauthenticated request got a 302. When fetch follows the redirect and fetches HTML (HyperText Markup Language), you see a meaningless JSON error in Angular. I overrode the cookie events:
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "bookstore.auth";
// The Angular client calls these endpoints with fetch; a redirect to a login page makes no sense
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
unauthenticated creation-options: 401
Conditional UI: Showing Passkeys in Autofill
Conditional UI means passkeys appear in the autofill list of the user name field. The user doesn't look for a separate "sign in with a passkey" button; they click the e-mail field and see their registered passkey. There are two conditions: the input's autocomplete value must contain webauthn, and a get() with mediation: 'conditional' must be started as soon as the page opens:
async ngOnInit(): Promise<void> {
if (await this.passkeys.conditionalUiAvailable()) {
// conditional UI: passkeys appear in the username field's autofill list
this.passkeys.signIn(null, 'conditional', this.conditional.signal)
.then(() => this.status.set('signed-in (autofill)'))
.catch(() => { /* aborted or dismissed: the form stays usable */ });
}
}
async passkeyLogin(email: string | null): Promise<void> {
// a modal request cannot run while the conditional one is pending
this.conditional.abort();
await this.run(() => this.passkeys.signIn(email), 'signed-in (passkey)');
}
The input in the template: <input id="email" type="email" autocomplete="username webauthn" />. The conditional request waits in the background; if the user presses the button it must first be cancelled with an AbortController, because the browser doesn't allow two WebAuthn requests at once. I also cancel it when the component is destroyed (DestroyRef), otherwise the pending request is left hanging when the user navigates away.
End to End: Headless Chrome and a Virtual Authenticator
Testing a passkey flow by clicking around is slow and not repeatable. The Chrome DevTools Protocol's WebAuthn domain adds a software authenticator instead of a real device. I put the Angular build into the lab API's wwwroot and drove headless Chrome with a Node script:
await send('WebAuthn.enable', { enableUI: false });
const auth = await send('WebAuthn.addVirtualAuthenticator', {
options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
});
The script presses the buttons of the real Angular component and reads the status on the screen:
password login -> signed-in (password)
add passkey -> passkey added
GET passkeys -> [{"id":"BFfTOg+PXeLmG3URY+oL9VZk/VycIQ44bB3aYXFyviM=","name":"Lab virtual authenticator","createdAt":"2026-11-08T06:07:50.370046+00:00","signCount":1,"isBackedUp":false,"isBackupEligible":false,"isUserVerified":true,"transports":["internal"]}]
sign out -> signed-out
GET /me -> 401
passkey login -> signed-in (passkey)
GET /me -> {"name":"ada@bookstore.test","amr":["pwd"]}
authenticator -> [{"rpId":"localhost","isResidentCredential":true,"signCount":2}]
origin -> http://127.0.0.1:19170
passkey login -> error: SecurityError This is an invalid domain.
Password sign-in, adding a passkey, signing out, passkey sign-in without typing an e-mail, and an attempt from another origin. Because the virtual authenticator is device-bound, isBackupEligible is false. The same script can run in a CI (Continuous Integration) pipeline. I couldn't test the conditional UI's autofill selection this way: headless Chrome doesn't show the autofill list; that part needs a manual try in a real browser.
Identity's Limits
The Microsoft documentation describes Identity's passkey support as "not a general-purpose WebAuthn library, but focused on Identity's authentication scenarios". In practice there are these limits. A passkey cannot be used as the second step of 2FA; it is a primary sign-in method, and PasskeySignInAsync skips two-factor authentication (bypassTwoFactor: true in the source code). The amr claim is pwd, as in the opening output; if you want to tell passkeys apart, verify with PerformPasskeyAssertionAsync in your own endpoint and add your own claim (for example hwk or swk from RFC (Request for Comments) 8176) with SignInWithClaimsAsync. The attestation statement isn't verified; for an enterprise "only these devices" policy you have to fill in the VerifyAttestationStatement callback yourself. The ready-made UI is only in the Blazor Web App template.
With .NET 11, Identity starts giving passkeys an automatic display name from the AAGUID (Authenticator Attestation Globally Unique Identifier) for known providers (such as Google Password Manager, iCloud Keychain, Windows Hello). Since .NET 11 isn't GA (General Availability) yet, I don't give code for it here.
In Production
HTTPS (HTTP Secure) is mandatory; the lab runs on http://localhost only because localhost counts as a secure context. All cookies, including the state cookie, must be Secure. After the first passkey, suggest adding a second one, and give the user a screen to manage their passkey list (rename, delete); put deleting and adding passkeys behind the step-up from post 5.
For measurement, the sign-in counts in Identity's .NET 10 metrics and the change in the number of passkeys over time are a sufficient start. As the share of users with passkeys grows, you expect password reset and account takeover support tickets to drop; watch these two numbers side by side.
Common Pitfalls
First, leaving ServerDomain empty. As you saw, the RP ID then comes from the Host header. Second, calling IPasskeyHandler<TUser> directly and carrying the attestation state to the client.
Third, not catching PasskeySignInAsync's InvalidOperationException and producing a 500 on every expired sign-in. Fourth, counting a passkey sign-in as MFA (Multi-Factor Authentication) by looking at amr; Identity writes pwd.
Fifth, starting the modal request without cancelling the conditional one. The browser rejects the second request, and nothing happens when the user presses "sign in with a passkey".
A Small Experiment
★☆☆ Run the lab without Passkeys:ServerDomain and request creation options with a forged Host header. Then add "AllowedHosts": "localhost" to appsettings.json and see what the same request returns.
★★☆ Write a separate endpoint for passkey sign-in: verify with PerformPasskeyAssertionAsync, update the counter with AddOrUpdatePasskeyAsync and give hwk as amr with SignInWithClaimsAsync. Extend the step-up policy from post 5 to accept passkey sign-ins too.
★★★ Add a conditional UI scenario to the E2E (end-to-end) script: load the page, confirm the credential exists with WebAuthn.getCredentials, then show with Runtime.evaluate that the conditional request is pending and gets cancelled when the modal button is pressed.
We have seen the modern ways to authenticate: cookies, Identity, MFA and passkeys. In the next post we return to the tokens that carry identity: the inside of a JWT (JSON Web Token), JWS (JSON Web Signature) and JWE (JSON Web Encryption), algorithm choice, kid, JWKS (JSON Web Key Set) and seamless signing key rotation.