Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 12 additions & 17 deletions aspnetcore/security/authentication/passkeys/blazor.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
title: Implement passkeys in ASP.NET Core Blazor Web Apps
ai-usage: ai-assisted
author: guardrex
description: Learn how to implement passkeys authentication in ASP.NET Core Blazor Web Apps.
ms.author: wpickett
monikerRange: '>= aspnetcore-10.0'
ms.date: 10/30/2025
ms.date: 08/31/2026
uid: security/authentication/passkeys/blazor
zone_pivot_groups: implementation
---
Expand Down Expand Up @@ -191,6 +192,12 @@ Add the following model classes to the project in the `Components/Account` folde
* [`Components/Account/PasskeyInputModel.cs`](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyInputModel.cs): Holds the JSON passkey credential for passkey sign-in operations (`Login` component) and adding passkeys (`Passkeys` component).
* [`Components/Account/PasskeyOperation.cs`](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyOperation.cs): Defines the authentication action to be performed (`PassKeySubmit` component), either registering a new passkey (`Create`/0) or authenticating with an existing passkey (`Request`/1).

:::moniker range=">= aspnetcore-11.0"

* [`Components/Account/PasskeyAuthenticators.cs`](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/PasskeyAuthenticators.cs): Maps Authenticator Attestation GUIDs (AAGUIDs) to friendly display names, so a new passkey from a known authenticator is named automatically instead of prompting the user.

:::moniker-end

## Create the `PasskeySubmit` component

Add the following `PasskeySubmit` component to handle passkey operations:
Expand All @@ -207,26 +214,14 @@ Add the following JavaScript file to handle WebAuthn API interactions:

Update the `IdentityComponentsEndpointRouteBuilderExtensions.cs` file (or create the file if it doesn't exist and call `MapAdditionalIdentityEndpoints` in the [`Program` file](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Program.cs#L129-L130)) to include the passkey-specific endpoints:

[`/PasskeyCreationOptions` and `/PasskeyRequestOptions` endpoints](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs#L53-L90)
[`/PasskeyCreationOptions` and `/PasskeyRequestOptions` endpoints](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs#L80-L132)

## Update the Login page

Replace the existing `Login` component with the following component and update the `BlazorWebCSharp._1.Data` namespace to match the app (for example: `Contoso.Components.Account.Data`):

[`Components/Account/Pages/Login.razor`](https://github.com/dotnet/aspnetcore/blob/main/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor)

## Add a redirect method to the `IdentityRedirectManager` class

Add the following method to the `IdentityRedirectManager` class in `Components/Account/IdentityRedirectManager.cs`:

```csharp
public void RedirectToInvalidUser(
UserManager<ApplicationUser> userManager, HttpContext context) =>
RedirectToWithStatus("Account/InvalidUser",
$"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.",
context);
```

## Create passkey management pages for adding and renaming passkeys

Add the following `Passkeys` component for managing passkeys and update the `BlazorWebCSharp._1.Data` namespace to match the app (for example: `Contoso.Components.Account.Data`):
Expand Down Expand Up @@ -254,13 +249,13 @@ In `Components/Account/Shared/ManageNavMenu.razor`, add the following [`NavLink`
In the `App` component (`Components/App.razor`), locate the [Blazor script](xref:blazor/project-structure#location-of-the-blazor-script) tag:

```razor
<script src="_framework/blazor.web.js"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
```

Immediately after the Blazor script tag, add a reference to the `PasskeySubmit` JavaScript module:

```razor
<script src="Components/Account/Shared/PasskeySubmit.razor.js" type="module"></script>
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
```

:::zone-end
Expand All @@ -282,7 +277,7 @@ After a passkey is registered:
1. Sign out of the app.
1. On the login page, enter your email address.
1. Select **Log in with a passkey**.
4. Follow the browser's prompts to authenticate with your passkey.
1. Follow the browser's prompts to authenticate with your passkey.
1. Navigate to `Account/Manage/Passkeys` to add, rename, or delete passkeys.
1. If the passkey supports passkey autofill (conditional UI) for login, test the passkey autofill feature by selecting the email input field when you have saved passkeys.

Expand Down
148 changes: 147 additions & 1 deletion aspnetcore/security/authentication/passkeys/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ author: guardrex
description: Discover how to enable Web Authentication API (WebAuthn) passkeys in ASP.NET Core apps.
ms.author: wpickett
monikerRange: '>= aspnetcore-10.0'
ms.date: 08/07/2026
ms.date: 08/31/2026
uid: security/authentication/passkeys/index
---
# Enable Web Authentication API (WebAuthn) passkeys
Expand Down Expand Up @@ -453,6 +453,8 @@ After registration is initiated, the browser must obtain creation options from t

From the browser's perspective, this step involves making an HTTP request to the server:

:::moniker range="< aspnetcore-11.0"

```javascript
async function createCredential(headers, signal) {
// Step 2: Request creation options from the server
Expand All @@ -469,6 +471,27 @@ async function createCredential(headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function createCredential(signal) {
// Step 2: Request creation options from the server
const optionsResponse =
await fetchWithErrorHandling('/Account/PasskeyCreationOptions',
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
return await navigator.credentials.create({ publicKey: options, signal });
}
```

:::moniker-end

The application should define an endpoint that generates these options:

```csharp
Expand Down Expand Up @@ -522,6 +545,8 @@ The <xref:Microsoft.AspNetCore.Identity.IdentityPasskeyOptions.UserVerificationR

With the creation options available, the client-side JavaScript passes the options to the WebAuthn API to create a new credential:

:::moniker range="< aspnetcore-11.0"

```javascript
async function createCredential(headers, signal) {
// Step 4: Parse the options and request a new credential from the authenticator
Expand All @@ -538,6 +563,27 @@ async function createCredential(headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function createCredential(signal) {
// Step 4: Parse the options and request a new credential from the authenticator
const optionsResponse =
await fetchWithErrorHandling('/Account/PasskeyCreationOptions',
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
return await navigator.credentials.create({ publicKey: options, signal });
}
```

:::moniker-end

The `parseCreationOptionsFromJSON` function converts the JSON response into the format expected by the WebAuthn API, and `navigator.credentials.create()` initiates the credential creation process with the authenticator.

### Step 5: Authenticator interaction
Expand All @@ -548,6 +594,8 @@ At this point, the browser communicates with the authenticator to create the cre

After the authenticator creates the credential, the browser must send the credential back to the server for verification and storage. The credential must be serialized to JSON before submission:

:::moniker range="< aspnetcore-11.0"

```javascript
async function createCredential(headers, signal) {
// Step 6: The credential is returned from navigator.credentials.create()
Expand All @@ -565,6 +613,28 @@ async function createCredential(headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function createCredential(signal) {
// Step 6: The credential is returned from navigator.credentials.create()
// and is serialized to JSON for submission to the server
const optionsResponse =
await fetchWithErrorHandling('/Account/PasskeyCreationOptions',
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
return await navigator.credentials.create({ publicKey: options, signal });
}
```

:::moniker-end

In the Blazor Web App template, the returned credential is automatically serialized and submitted through a form, but the exact submission mechanism varies by application.

### Step 7: Server verification and storage
Expand Down Expand Up @@ -643,6 +713,8 @@ Users typically initiate passkey authentication through a dedicated button or li

The browser requests authentication options from the server to begin the authentication process. These options include a list of acceptable credentials and a new challenge to be signed:

:::moniker range="< aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, headers, signal) {
// Step 2: Request authentication options from the server
Expand All @@ -659,6 +731,27 @@ async function requestCredential(email, mediation, headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, signal) {
// Step 2: Request authentication options from the server
const optionsResponse =
await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`,
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
return await navigator.credentials.get({ publicKey: options, mediation, signal });
}
```

:::moniker-end

The <xref:Microsoft.AspNetCore.Identity.SignInManager%601.MakePasskeyRequestOptionsAsync%2A> method generates these options. When you provide a specific user, it includes only that user's credentials in the allow list. When called without a user, it generates options suitable for conditional UI or username-less authentication:

```csharp
Expand All @@ -684,6 +777,8 @@ The server generates authentication options using the same <xref:Microsoft.AspNe

The client-side JavaScript passes the authentication options to the WebAuthn API to request an assertion from the authenticator:

:::moniker range="< aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, headers, signal) {
// Step 4: Parse the options and request an assertion from the authenticator
Expand All @@ -700,6 +795,27 @@ async function requestCredential(email, mediation, headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, signal) {
// Step 4: Parse the options and request an assertion from the authenticator
const optionsResponse =
await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`,
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
return await navigator.credentials.get({ publicKey: options, mediation, signal });
}
```

:::moniker-end

The `navigator.credentials.get()` call initiates the authentication process with the authenticator, which prompts the user for verification.

### Step 5: Authenticator verification
Expand All @@ -710,6 +826,8 @@ The authenticator verifies the user's identity and signs the challenge with the

After the authenticator creates the signed assertion, the browser serializes it to JSON and submits it to the server:

:::moniker range="< aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, headers, signal) {
// Step 6: The assertion is returned from navigator.credentials.get()
Expand All @@ -727,6 +845,28 @@ async function requestCredential(email, mediation, headers, signal) {
}
```

:::moniker-end

:::moniker range=">= aspnetcore-11.0"

```javascript
async function requestCredential(email, mediation, signal) {
// Step 6: The assertion is returned from navigator.credentials.get()
// and is serialized to JSON for submission to the server
const optionsResponse =
await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`,
{
method: 'POST',
signal,
});
const optionsJson = await optionsResponse.json();
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
return await navigator.credentials.get({ publicKey: options, mediation, signal });
}
```

:::moniker-end

The submission mechanism varies by app but typically involves either a form submission or an API call.

### Step 7: Server verification
Expand All @@ -753,6 +893,12 @@ The <xref:Microsoft.AspNetCore.Identity.SignInManager%601.PasskeySignInAsync%2A>

If all checks pass, the method signs in the user and returns a `SignInResult` indicating success.

:::moniker range=">= aspnetcore-11.0"

The method distinguishes between a recoverable user action and an app error. If the passkey session state is missing or expired, for example because the user took too long to respond to the authenticator, the method returns <xref:Microsoft.AspNetCore.Identity.SignInResult.Failed?displayProperty=nameWithType>. Calling the method without a preceding call to <xref:Microsoft.AspNetCore.Identity.SignInManager%601.MakePasskeyRequestOptionsAsync%2A> throws an <xref:System.InvalidOperationException>, because that indicates a problem in the app rather than something the user can retry.

:::moniker-end

For scenarios requiring more control, you can use <xref:Microsoft.AspNetCore.Identity.SignInManager%601.PerformPasskeyAssertionAsync%2A> directly to validate the assertion without immediately signing in the user:

* <xref:Microsoft.AspNetCore.Identity.SignInManager%601.PerformPasskeyAssertionAsync%2A> returns a <xref:Microsoft.AspNetCore.Identity.PasskeyAssertionResult> containing the authenticated user and updated passkey information.
Expand Down
Loading