Passkeys in .NET 10 Blazor Apps with ASP.NET Identity

Maarten Balliauw |

Passwordless authentication is gaining momentum, with Microsoft, Google, Apple, and many others providing Passkey support in their services. But how do you add the latest authentication to your identity solutions?

In a previous post, we saw how passkeys solve fundamental password problems by using public key cryptography to provide a more secure, phishing-resistant authentication method that improves user experience while eliminating the vulnerabilities of traditional passwords.

With the upcoming release of .NET 10, the ASP.NET Core team has taken a step forward by introducing built-in passkey support in ASP.NET Identity. In this post, we'll look at the new Blazor project template in .NET 10, and how it makes secure authentication using passkeys more accessible to .NET developers.

Other posts in this series:

What Are Passkeys?

Before we dive into code, let's do a quick recap of passkeys. A "passkey" is the common name used for a specification called WebAuthn. Its goal is to eliminate the practice of usernames and passwords, in both the browser and native apps.

The specification describes the mechanism of using a cryptographic credential that consists of a public-private key pair, where the user can store a private key on their authenticator (the browser, operating system, or a hardware key), and the service stores the matching public key, which sets the stage for future authentication attempts.

To authenticate, the service requests proof that the authenticator possesses the private key by sending a challenge that the authenticator must sign. The signature must also include the service origin. This results in a phishing-resistant credential (another service always signs another origin). Server-side breaches are also no longer a problem, as the service only knows the public key. The private key never leaves the authenticator.

The WebAuthn specification also describes two so-called "ceremonies", where the interaction between the authenticator and the service is defined for registering a passkey credential, and for authenticating with a passkey credential.

Registration uses a browser (or OS) API, navigator.credentials.create(), which establishes a passkey credential based on the data received from the service's PasskeyCreationOptions endpoint:

sequenceDiagram
    participant Client
    participant Authenticator
    participant Server
    Client->>+Server: GET /PasskeyCreationOptions
    Server->>-Client: JSON
    Client->>+Authenticator: navigator.credentials.create(json)
    Authenticator->>-Client: Credential
    Client->>+Server: POST /Register
    Server->>-Client: All set!

When a user initiates passkey registration, the service sends creation options to the browser. These creation options describe which cryptographic algorithms to use, the key length, whether there are specific requirements for the authenticator, and more. The browser then invokes the authenticator (Windows Hello, Touch ID, Face ID, or a password manager), which prompts the user to prove their presence using a PIN code or biometrics, adding yet another security layer. The authenticator then creates a new key pair and sends the public key to the service. Authenticator requirements are then stored on the server, alongside the public key.

Authentication uses the navigator.credentials.get() API. When logging in with a passkey, it signs the challenge data received from the service's PasskeyRequestOptions endpoint.

sequenceDiagram
    participant Client
    participant Authenticator
    participant Server
    Client->>+Server: GET /PasskeyRequestOptions
    Server->>-Client: JSON
    Client->>+Authenticator: navigator.credentials.get(json)
    Authenticator->>-Client: Credential
    Client->>+Server: POST /Login
    Server->>-Client: All set!

When the user initiates a passkey login, the service sends a unique challenge to the browser. The browser then invokes the authenticator and, once the user verifies their PIN code or biometrics again, the authenticator uses the private key that matches the service origin to sign the challenge. This signed challenge is called an assertion, which is sent back to the service where it is verified using the stored public key and authenticator options. When all is valid, the user will be logged in.

Keep these ceremonies in mind as we walk through the new .NET 10 Blazor template.

Creating A .NET 10 Blazor Project With Passkey Support

Time to look at some code! If you want to follow along, make sure to install the latest .NET 10 version on your system and run dotnet new update to get the latest version of the templates

Start by creating a new project using the Blazor Web App template with server-side rendering and individual authentication as options. In your terminal, you can use the following command to create a project:

dotnet new blazor -o PasskeysExample --auth Individual

When you open your newly created project and run it, you will see the typical Blazor project contents. There is a registration page and a login page as well. Execute the database migrations before running the project to ensure a SQLite database is created with the necessary ASP.NET Identity tables. If you have the Entity Framework Core tools installed, you can run:

dotnet ef database update

If you want to create a passkey for your template-created site, you'll first need to register with a username and password (which is a bit strange, since passkeys are here to remove passwords from the equation). Next, confirm your account using the link shown in the UI, and then navigate to https://localhost:<port>/Account/Manage/Passkeys.

Manage passkeys with .NET 10 project template

Clicking Add a new passkey prompts your device to create a new passkey. If you look at the options presented, you can store the passkey in your operating system's credential store, on another device, or in your browser profile. You can use any option you want here, but I'd recommend trying the “other device” option to see cross-device passkeys in action.

Add passkey with ASP.NET Identity

After following the device system flow, which will include entering a PIN, fingerprint, or face recognition, you will be prompted by the Blazor application to give your passkey a descriptive name. Save it, and you will see the passkey is now listed in your profile:

Added passkey with a descriptive name

Nice! Try logging out, and then use the Log in with a passkey link to prompt your authenticator:

Logging in with passkey in .NET 10

Again, follow the instructions and use Windows Hello, Touch ID, Face ID, or a password manager to complete your login.

Congratulations! Your first Blazor passkey login with .NET 10!

Passkeys in Blazor - Under The Hood

With the passkey ceremonies and the user-facing walkthrough in mind, let's look at the project and code required to make passkeys work in .NET 10.

Several files in your project are relevant to passkey support in .NET 10:

  • Program.cs, where ASP.NET Identity is registered with the service provider, and the server-side endpoints to support passkeys are added to the ASP.NET Core pipeline.
  • Components/Account/Shared/PasskeySubmit.razor and Components/Account/Shared/PasskeySubmit.razor.js contain a Blazor component to add a passkey registration or login button
  • Components/Account/Manage/Passkeys.razor, the page where you can manage passkeys
  • Components/Account/Manage/RenamePasskey.razor, where you can rename a passkey
  • Components/Account/Pages/Login.razor, where you can log in using username/password or with a passkey
  • Components/Account/Pages/PasskeyInputModel.cs and Components/Account/Pages/PasskeyOperation.cs are helper classes to make passkey login work

We won't go into detail about all of these, but we do want to highlight the more important classes and logic that enable passkey support in .NET 10.

PasskeySubmit Blazor Component And JavaScript

A key component of passkeys in .NET 10 is the PasskeySubmit component and its associated JavaScript file.

The Blazor component itself is relatively straightforward: based on parameters, it renders a submit button named __passkeySubmit, so that a passkey registration or login can be recognized on the server, and renders a passkey-submit web component:

<button type="submit" name="__passkeySubmit" @attributes="AdditionalAttributes">@ChildContent</button>
<passkey-submit
    operation="@Operation"
    name="@Name"
    email-name="@EmailName"
    request-token-name="@tokens?.HeaderName"
    request-token-value="@tokens?.RequestToken">
</passkey-submit>

The passkey-submit web component is defined in the Components/Account/Manage/Passkeys.razor.js file. Without diving into the details, the obtainCredential method is called when clicking the __passkeySubmit button. This method starts the registration or authentication ceremony by using the relevant method depending on the provided @Operation:

  • For registration, createCredential is used, which calls /Account/PasskeyCreationOptions on the server and then uses the browser's navigator.credentials.create() API.
  • For authentication, requestCredential calls /Account/PasskeyRequestOptions on the server and then uses the browser's navigator.credentials.get() API.

obtainCredential then serializes the resulting credential to JSON, injects it into the current HTML form, which is then submitted to the server.

Note: The Passkeys.razor.js code currently does not support password managers like 1Password. Some password managers do not implement PublicKeyCredential.prototype.toJSON correctly, which is required for JSON.stringify() to work in the obtainCredential logic.

Use this replacement that adds support for password managers by using custom JSON serialization of the passkey credential created by the password manager.

PasskeyCreationOptions and PasskeyRequestOptions Server-Side Endpoints

Before submitting a passkey credential for registration or authentication, one of two endpoints on the server is called to provide the necessary authenticator options and authentication challenge to the browser.

The Blazor project template in .NET registers these two endpoints in Program.cs, adding them to the ASP.NET Core pipeline using the following extension method:

app.MapAdditionalIdentityEndpoints();

This extension method is part of your project, and can be found in the IdentityComponentsEndpointRouteBuilderExtensions class. It contains several minimal API endpoints to support ASP.NET Identity, including PasskeyCreationOptions and PasskeyRequestOptions.

Both methods use the UserManager<ApplicationUser> and SignInManager<ApplicationUser> classes from ASP.NET Identity to store and retrieve passkey information from the underlying database, and to perform cryptographic operations required for working with passkeys. All of these are abstracted away, making these endpoints relatively straightforward.

Let's look at PasskeyCreationOptions:

accountGroup.MapPost("/PasskeyCreationOptions", async (
    HttpContext context,
    [FromServices] UserManager<ApplicationUser> userManager,
    [FromServices] SignInManager<ApplicationUser> signInManager,
    [FromServices] IAntiforgery antiforgery) =>
{
    await antiforgery.ValidateRequestAsync(context);

    var user = await userManager.GetUserAsync(context.User);
    if (user is null)
    {
        return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
    }

    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");
});

ASP.NET Identity requires a logged-in user to register a new passkey. In addition, the user must already be known by UserManager<ApplicationUser>. Next, passkey creation options are created by calling MakePasskeyCreationOptionsAsync, which generates the JSON returned to the browser. These options are created using the user ID, username, and display name, combined with options you can specify globally for your application.

If you're curious, this is the JSON returned to the browser:

{
  "rp": {
    "name": "localhost",
    "id": "localhost"
  },
  "user": {
    "id": "YmI3OGE1NTItMmU1OS00OWQyLTk3YjMtNDNmNTVjNDMzNGUy",
    "name": "test@example.com",
    "displayName": "test@example.com"
  },
  "challenge": "TMdX6DBNi3Sz7wqK4cog0VUKoWg1xyxzAGQPeBX5XAY",
  "pubKeyCredParams": [
    {
      "type": "public-key",
      "alg": -7
    },
    // ... more algorithms ...
  ],
  "timeout": 300000,
  "excludeCredentials": [],
  "authenticatorSelection": {
    "requireResidentKey": false
  },
  "hints": [],
  "attestationFormats": []
}

The PasskeyRequestOptions endpoint follows similar logic, where SignInManager<ApplicationUser>’s MakePasskeyRequestOptionsAsync method generates the JSON that contains the challenge for authentication by the browser.

Identity Passkey Options

Now is a good moment for a swift side step into setting passkey options. While not part of the project template, you can configure passkeys in Program.cs by registering IdentityPasskeyOptions at application startup.

builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    options.ServerDomain = "example.com";
    
    // ...
});

Using these, you can configure various settings such as the required challenge length, user verification options, multiple origins, authenticator requirements, etc. These options are used by all of the passkey-related methods in UserManager<ApplicationUser> and SignInManager<ApplicationUser>.

Passkey Registration

So far, we have seen the PasskeySubmit component handles a large part of the passkeys ceremonies by querying the server for passkey creation or request options, working with the browser APIs, and then submitting the result to the server, for example, to the component defined in the Components/Account/Manage/Passkeys.razor file.

When clicking the Add a new passkey button, the following form is submitted. Keep in mind that the PasskeySubmit component here injects the passkey credential serialized as JSON, which is bound to the Input.CredentialJson model property.

<form @formname="add-passkey" @onsubmit="AddPasskey" method="post">
    <AntiforgeryToken/>
    <PasskeySubmit Operation="PasskeyOperation.Create" Name="Input" class="btn btn-primary">Add a new passkey</PasskeySubmit>
</form>

If you're curious about what the Input.CredentialJson JSON looks like, here's an example. You will see the type of authenticator is included, attestation information, the client data (base64-encoded), the public key that should be stored, and more.

{
  "authenticatorAttachment": "platform",
  "clientExtensionResults": {},
  "id": "G5TKneiLXfq-uHHoXd6I4AVJivr8ht_U0ywP5KNxzLw",
  "rawId": "G5TKneiLXfq-uHHoXd6I4AVJivr8ht_U0ywP5KNxzLw",
  "response": {
    "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAIBuUyp3oi136vrhx6F3eiOAFSYr6_Ibf1NMsD-Sjccy8pQECAyYgASFYIHI_oh1WvXnpkn5HscRkFaC4JDfsGQrPDtMU5gNgfdboIlgg6xZ7h-mFV1QUUrH6Z7SlhFyTgKq1epp6ZZpFUQejLys",
    "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAIBuUyp3oi136vrhx6F3eiOAFSYr6_Ibf1NMsD-Sjccy8pQECAyYgASFYIHI_oh1WvXnpkn5HscRkFaC4JDfsGQrPDtMU5gNgfdboIlgg6xZ7h-mFV1QUUrH6Z7SlhFyTgKq1epp6ZZpFUQejLys",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoienFoZ3dscmc0T2luWjBFNEg2MFBCZy03TmhrcldWNkc4ZWdYYVdFZ1hkZyIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjcyMTciLCJjcm9zc09yaWdpbiI6ZmFsc2UsIm90aGVyX2tleXNfY2FuX2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgifQ",
    "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcj-iHVa9eemSfkexxGQVoLgkN-wZCs8O0xTmA2B91ujrFnuH6YVXVBRSsfpntKWEXJOAqrV6mnplmkVRB6MvKw",
    "publicKeyAlgorithm": -7,
    "transports": [
      "internal"
    ]
  },
  "type": "public-key"
}

Now, back to Blazor and the Components/Account/Manage/Passkeys.razor page where this data is processed.

The AddPasskey method verifies that a user is present, no error was returned from the browser's navigator.credentials API, the passkey credential JSON is available, and then verifies the passkey registration and stores the passkey in the ASP.NET Identity database. Finally, the user is redirected to the RenamePasskey page to give the newly added passkey a more descriptive name.

private async Task AddPasskey()
{
    if (user is null)
    {
        RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
        return;
    }

    if (!string.IsNullOrEmpty(Input.Error))
    {
        RedirectManager.RedirectToCurrentPageWithStatus($"Error: {Input.Error}", HttpContext);
        return;
    }

    if (string.IsNullOrEmpty(Input.CredentialJson))
    {
        RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext);
        return;
    }

    var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson);
    if (!attestationResult.Succeeded)
    {
        RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext);
        return;
    }

    var addPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);
    if (!addPasskeyResult.Succeeded)
    {
        RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext);
        return;
    }

    // Immediately prompt the user to enter a name for the credential
    var credentialIdBase64Url = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId);
    RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{credentialIdBase64Url}");
}

The interesting methods used during registration are SignInManager.PerformPasskeyAttestationAsync, and UserManager.AddOrUpdatePasskeyAsync. The first validates the submitted passkey, and the second stores the passkey in the underlying data store.

Wait a minute!

How can PerformPasskeyAttestationAsync perform passkey validation, if all the information it has is the JSON sent from the browser? Isn't there some server-side session state required to keep track, at a minimum, of the challenge the browser needs to sign?

You're right!

When the PasskeyRequestOptions endpoint was called earlier, the SignInManager's MakePasskeyRequestOptionsAsync method not only created passkey options, it also stored them in an authentication cookie that belongs to the Identity.TwoFactorUserId authentication scheme. While this is an implementation detail, it's interesting to see how Microsoft uses a separate ClaimsIdentity to store the state the server requires, without persisting the data on the server. This is clever because it prevents server-side data store pollution from unfinished passkey ceremonies.

If you want to do some spelunking, the following call stacks are worth looking at:
SignInManager.MakePasskeyRequestOptions()
SignInManager.StorePasskeyAuthenticationInfoAsync()
SignInManager.PerformPasskeyAttestationAsync()
SignInManager.RetrievePasskeyAuthenticationInfoAsync()

The authentication ceremony works similarly.

Passkeys Storage With ASP.NET Identity And Entity Framework Core

One thing we have not yet explored is the database used by ASP.NET Identity. In Program.cs, you will find Entity Framework Core is configured to use a SQLite database that can be accessed through the ApplicationDbContext.

The ApplicationDbContext database context extends the IdentityDbContext<ApplicationUser> context found in ASP.NET Identity, which makes sure the necessary user management database tables are accessible.

ASP.NET Identity is also registered in Program.cs, with a call to AddIdentityCore:

builder.Services.AddIdentityCore<ApplicationUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = true;
        options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();

You may have seen this registration in previous versions of .NET Core, or you may be using AddIdentity or AddDefaultIdentity instead. This registration adds the required services to make the UserManager and SignInManager available to your application, backed by the ApplicationDbContext database context.

In the .NET 10 template, you will find that the schema version configuration is set explicitly:

options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;

The schema version is an important setting as it ensures the passkeys tables are created as part of the ASP.NET Identity data access layer.

You'll find a version of the required Entity Framework migration already contained in the project. Tables are the same as in previous .NET versions; however, for .NET 10, a new AspNetUserPasskeys table is created as well:

migrationBuilder.CreateTable(
    name: "AspNetUserPasskeys",
    columns: table => new
    {
        CredentialId = table.Column<byte[]>(
            type: "BLOB", maxLength: 1024, nullable: false),
        UserId = table.Column<string>(type: "TEXT", nullable: false),
        Data = table.Column<string>(type: "TEXT", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId);
        table.ForeignKey(
            name: "FK_AspNetUserPasskeys_AspNetUsers_UserId",
            column: x => x.UserId,
            principalTable: "AspNetUsers",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });

This table contains the credential ID, a byte array that identifies the credential a user has stored. The user ID is also stored to map the passkey to an ApplicationUser in ASP.NET Identity. Lastly, the passkey data, including creation options, is stored in the data field as JSON.

Conclusion

In this post, we looked at Passkey support in .NET 10 Blazor applications using ASP.NET Identity. We revisited WebAuthn, the specification for passkeys, and illustrated the client-server-authenticator interactions.

We've seen how to create a .NET 10 Blazor project with individual authentication, demonstrating how to register and log in with a passkey, including managing passkeys within the application.

The second part of this blog post explored the technical implementation of Passkey support in .NET 10, highlighting the key components involved.

While we haven't gone into all the details of the .NET 10's Passkey implementation with ASP.NET Identity, we hope this post serves as a starting point to understanding the components involved and where to find specific logic.

In the next post, we'll look at how in a passkey the origin matters in many ways, especially when thinking about implementing passkey support in your production systems.

As always, we welcome your thoughts and feedback in the comments below.