blog post image
Andrew Lock avatar

Andrew Lock

~13 min read

Passkey support for ASP.NET Core identity

Exploring the .NET 10 preview - Part 6

Share on:

In this post I look at the passkey support added to ASP.NET Core Identity in .NET 10 preview 6. I primarily focus on the changes included in the Blazor template, looking at what's been added and changed as part of the passkey support. Finally, we take a peek at the source code of the template changes, to understand the new WebAuthn interactions with the browser.

This post was written using the features available in .NET 10 preview 6. Many things may change between now and the final release of .NET 10.

What are passkeys?

Passkeys provide a secure, unphishable, password-less way to authenticate with websites and apps. They're based on standards provided by FIDO (Fast IDentity Online) and let you sign in to apps using the same mechanisms that you use to unlock your laptop or phone: such as your biometrics or a PIN. They're inherently more secure than passwords, though they do have some usability challenges when it comes to sharing your passkeys between multiple devices.

In .NET 10 preview 6, ASP.NET Core has added support for passkeys as an alternative way to login to apps that are using ASP.NET Core Identity. They don't completely replace passwords in the current templates, so you still need to register with a password initially, but you can add a passkey to your account subsequently for easier logging in.

Personally, this seems like it fundamentally misses the point of passkeys. The whole point of passkeys in my eyes is that you don't have passwords, so you can't be phished. Having an ever-present mandatory password seems to defeat half the purpose of passkeys.

In the next section I take a brief look at the default Blazor template with individual authentication enabled to see how it's changed from the .NET 9 version.

Trying out the new template

Passkey support was added in this giant PR, which added the new passkey abstractions to ASP.NET Core Identity and made the changes to the Blazor Web App template that we're looking at in this post. Before we get started, it's worth noting what "passkey supports" actually means. As noted in the PR description:

Note that the goal of this PR is to add support for passkey authentication in ASP.NET Core Identity. While it implements core WebAuthn functionality, it does not provide a complete and general-purpose WebAuthn/FIDO2 library. The public API surface is limited in order to enable long-term stability of the feature. Targeted extensibility points were added to enable functionality not implemented by default, most notably attestation statement validation. This allows the use of third-party libraries to fill the missing gaps, when desired. Community feedback may result in additional extensibility APIs being added in the future.

If you want (or need) a more full-featured library, then you might want to consider the Fido2 library which supports .NET 8 and above. You can also use this library in combination with the built-in passkey support to enable additional features such as attestation statement validation.

Before looking at the code, we'll start by creating a new web app with ASP.NET Core Identity, and explore what the new passkey support looks like in the UI.

Passkey support was added in .NET 10 preview 6, so you need to be using at least this version of the SDK. If you're using a later version, then things may change from what I show here.

Create a new Blazor Web App with individual authentication:

dotnet new blazor -au Individual

You can run the application using dotnet run or by pressing F5 in your IDE, and you'll be greeted with the familiar Blazor web app. Navigate to the Register page and create a new user:

The register page of the application

So far, there's no obvious difference. After creating your account, click the "Click here to confirm your account" link and then navigate to the login page.

Note that in the default template, users must still create a password for the account, even if they later want to use passkeys. This isn't a requirement of ASP.NET Core Identity itself, just of how the template works.

After registering, login, and navigate to the account page. Here you'll find a new section, Passkeys, which allows you to register a passkey:

The passkey management page of the application

Click Add a new passkey to initiate the registration process. Clicking this button will pop up a native dialog from your browser. If you're using a password manager with passkey support, like 1Password, then it will likely prompt you to save your passkeys there. Otherwise you'll get a native popup from your browser with your available options:

The passkey enrolment popup

What this dialog looks like and which options are available to you will depend on the device you're using. In the case above I was using a Windows device with a Windows Hello camera.

You'll notice there's also a Use another device option, which lets you use (for example) a nearby phone with biometric support to perform the authentication. You can read more about cross-device sign-in here.

Choose where to save your passkey, perform the necessary authentication, and you should see confirmation that the passkey is enrolled:

The passkey enrolment success popup

Now that you've saved the passkey to your device, the Blazor app prompts you to choose a name for the passkey. Technically the passkey is already saved at this point (with the name "Unnamed passkey") but you should choose a more descriptive key name and click Continue:

Naming a newly-enrolled passkey

Now that your passkey is enrolled you can enrol another passkey, rename an existing key, or delete the key from the passkeys page:

The manage passkeys page

Next we'll try-out the login flow. Logout of your account, and on the login page don't enter your username and password. Instead, click the Log in with a passkey link:

The login page contains a 'log in with a passkey' link

When you click this link, the browser will generally pop-up a window prompting you to choose a passkey to use to login with:

The 'use a saved passkey' dialog in Chrome on Windows

After choosing the saved passkey, you'll be prompted to authenticate with your device using a native prompt. In my case this involved another face-recognition Windows Hello authentication, but it will vary by device. After authenticating, you're immediately logged in to the website, without needing to enter a username and password.

As a small bonus, if you want to delete the passkeys saved on your Windows device, for example after testing with a sample app, go to the Windows Settings app and choose Accounts > Passkeys (or click this link). From there you can delete your old passkeys (but make sure you don't delete any you actually need!)

That pretty much covers all the user-facing changes to support passkeys in the new template. There's no ability to use passkeys as an additional factor for multi-factor authentication, or to remove the password associated with the account entirely.

To finish off this post we'll look at some of the code changes behind the passkey support, focusing on the parts that interact with the ASP.NET Core Identity system and the browser.

Looking at the code changes

All the code I show in this section is part of the template when you create a new Blazor Web App template using .NET 10 preview 6. There were also changes made to the ASP.NET Core Identity system to support the template additions, but I don't go into those here.

Note that the code shown here is specifically for .NET 10 preview 6. This code has already been updated for newer previews, and will likely change again before final GA release, so take it all with a pinch of salt!

On the UI side, the most important new component is Components/Account/Shared/PasskeySubmit.razor and its corresponding collocated JavaScript file PasskeySubmit.razor.js. The JavaScript in particular contains all the functions for calling the browser's WebAuthn features for interacting with passkeys. We'll look at this file in detail shortly.

Aside from the PasskeySubmit component, there are several new and updated components:

  • Components/Account/Pages/Login.razor—Updated to include the "log in with a passkey" link.
  • Components/Account/Shared/ManageNavMenu.razor—Updated to include the "Passkeys" menu item.
  • Components/Account/Manage/Passkeys.razor—The passkey management page for adding and deleting passkeys.
  • Components/Account/Manage/RenamePasskey.razor—The page for renaming passkeys.

On the backend of the application there are two main changes:

  • New APIs in IdentityComponentsEndpointRouteBuilderExtensions called by the Blazor components for interacting with ASP.NET Core Identity
  • A new EF Core migration for saving a user's passkey information to the database.

That covers pretty much all of the public-facing changes in the templates, so let's look at each of them in more detail.

We'll start with the PasskeySubmit component, the markup for which is shown below:

<button type="submit" name="__passkeySubmit" @attributes="AdditionalAttributes">@ChildContent</button>
<passkey-submit operation="@Operation" name="@Name" email-name="@EmailName"></passkey-submit>

The component itself is pretty simple, just a form submit button and a custom element called passkey-submit. If we take a look in PasskeySubmit.razor.js we can see how this custom-element is wired up. The outline of this is shown below, along with some bonus comments explaining various API calls:

// register a custom element definition
customElements.define('passkey-submit', class extends HTMLElement {
    static formAssociated = true;

    // connectedCallback fires when an element is inserted into the DOM
    connectedCallback() {
        // attaches the custom-element to a form
        this.internals = this.attachInternals();
        // grab the details passed as attributes to the element
        this.attrs = {
            operation: this.getAttribute('operation'),
            name: this.getAttribute('name'),
            emailName: this.getAttribute('email-name'),
        };

        // Register a submit handler on the form, and if it was triggered
        // by the __passkeySubmit button then try to submit a Passkey credential
        this.internals.form.addEventListener('submit', (event) => {
            if (event.submitter?.name === '__passkeySubmit') {
                event.preventDefault();
                // get or create a passkey credential and submit the form
                this.obtainCredentialAndSubmit();
            }
        });

        // try to auto-fill the passkey, to improve the user experience
        this.tryAutofillPasskey();
    }

    // disconnectedCallback fires when an element is removed from the DOM
    disconnectedCallback() {
        this.abortController?.abort();
    }

    async tryAutofillPasskey() {
        if (this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable()) {
            // If the component is in 'request' mode (i.e. login), 
            // and autofill is available and supported in the browser
            // then try to pre-autofill
            await this.obtainCredentialAndSubmit(/* useConditionalMediation */ true);
        }
    }

    async obtainCredentialAndSubmit(useConditionalMediation = false) {
        // AbortController works similarly to a CancelationToken in .NET
        this.abortController?.abort();
        this.abortController = new AbortController();
        const signal = this.abortController.signal;
        const formData = new FormData();
        try {
            let credential;
            // Either create a new credential or request an existing one
            if (this.attrs.operation === 'Create') {
                credential = await createCredential(signal);
            } else if (this.attrs.operation === 'Request') {
                const email = new FormData(this.internals.form).get(this.attrs.emailName);
                const mediation = useConditionalMediation ? 'conditional' : undefined;
                credential = await requestCredential(email, mediation, signal);
            } else {
                throw new Error(`Unknown passkey operation '${operation}'.`);
            }

            // convert the credential to JSON and store it in the form data
            const credentialJson = JSON.stringify(credential);
            formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
        } catch (error) {
            if (error.name === 'AbortError') {
                // Canceled by user action, do not submit the form
                return;
            }
            formData.append(`${this.attrs.name}.Error`, error.message);
            console.error(error);
        }

        // Set the form data and submit it
        this.internals.setFormValue(formData);
        this.internals.form.submit();
    }
});

This code shows all the behaviour added to the passkey-submit element. We're just missing the definition of two functions: createCredential() and requestCredential(), shown below:

// Called to create a new passkey
async function createCredential(signal) {
    // Call the ASP.NET Core Identity endpoint to get the passkey options for the app
    const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', {
        method: 'POST',
        signal,
    });

    // Convert the response to a passkey options JSON object
    const optionsJson = await optionsResponse.json();
    const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);

    // Trigger the browser to create a passkey credential using 
    // the provided options and return the credentials
    return await navigator.credentials.create({ publicKey: options, signal });
}

// Called to trigger a login using a passkey
async function requestCredential(email, mediation, signal) {
    // Call the ASP.NET Core Identity endpoint to get the passkey options for the app
    const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, {
        method: 'POST',
        signal,
    });

    // Convert the response to a passkey options JSON object
    const optionsJson = await optionsResponse.json();
    const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);

    // Trigger the browser to try to login to a passkey credential using
    // the provided options and return the credentials
    return await navigator.credentials.get({ publicKey: options, mediation, signal });
}

// Helper function for sending an HTTP request and returning the response
async function fetchWithErrorHandling(url, options = {}) {
    const response = await fetch(url, {
        credentials: 'include',
        ...options
    });
    if (!response.ok) {
        const text = await response.text();
        console.error(text);
        throw new Error(`The server responded with status ${response.status}.`);
    }
    return response;
}

These functions make calls to 2 API endpoints, exposed in IdentityComponentsEndpointRouteBuilderExtensions. The first is /Account/PasskeyCreationOptions, which is called when you're adding a passkey to an existing logged-in user's account:

internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var accountGroup = endpoints.MapGroup("/Account");
        // ...
        
        accountGroup.MapPost("/PasskeyCreationOptions", async (
            HttpContext context,
            [FromServices] UserManager<ApplicationUser> userManager,
            [FromServices] SignInManager<ApplicationUser> signInManager) =>
        {
            var user = await userManager.GetUserAsync(context.User);
            if (user is null)
            {
                return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
            }

            // Collect current user's details to create a PasskeyCreationArgs object
            var userId = await userManager.GetUserIdAsync(user);
            var userName = await userManager.GetUserNameAsync(user) ?? "User";
            var userEntity = new PasskeyUserEntity(userId, userName, displayName: userName);
            var passkeyCreationArgs = new PasskeyCreationArgs(userEntity);

            // Use the arguments to create the passkey options object
            // and return it as JSON, for use client-side
            var options = await signInManager.ConfigurePasskeyCreationOptionsAsync(passkeyCreationArgs);
            return TypedResults.Content(options.AsJson(), contentType: "application/json");
        });

        //...
    }
}

The SignInManager.ConfigurePasskeyCreationOptionsAsync() method is where all the actual work occurs (renamed to MakePasskeyCreationOptionsAsync in a future .NET 10 release). This method is responsible for generating the passkey options, storing them in an authentication cookie, and returning the JSON. For reference, the returned JSON will look something like this (I removed most of the pubKeyCredParams options for brevity):

{
    "rp": {
        "name": "localhost",
        "id": "localhost"
    },
    "user": {
        "id": "OWVhMDBjMDUtYjU4LThmODEtMWNlNWNihmYS00NWMmRlNjdi",
        "name": "[email protected]",
        "displayName": "[email protected]"
    },
    "challenge": "4ZIzlOlk9bTwB4veQVQc9w",
    "pubKeyCredParams": [
        {
            "type": "public-key",
            "alg": -7
        },
        {
            "type": "public-key",
            "alg": -37
        }
    ],
    "timeout": 60000,
    "excludeCredentials": [],
    "hints": [],
    "attestation": "none",
    "attestationFormats": []
}

These options are returned to the browser and are used to generate the passkey client-side.

The other API endpoint, PasskeyRequestOptions is almost identical, though as this is intended for logging-in, there's no authenticated user required at this point (though if you enter your username first, it can be used to improve the UX of choosing a passkey).

accountGroup.MapPost("/PasskeyRequestOptions", async (
    [FromServices] UserManager<ApplicationUser> userManager,
    [FromServices] SignInManager<ApplicationUser> signInManager,
    [FromQuery] string? username) =>
{
    var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
    var passkeyRequestArgs = new PasskeyRequestArgs<ApplicationUser>
    {
        User = user,
    };
    var options = await signInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs);
    return TypedResults.Content(options.AsJson(), contentType: "application/json");
});

Note that these options are used both during passkey creation and login with the PasskeySubmit component, but the results of that operation, i.e. the credential created or retrieved from the browser, are just stored in a form field and submitted. The handling of that data happens in the Passkeys and Login components.

The Passkeys component contains markup similar to the following, which places the PasskeySubmit component inside a form, and hooks up the AddPasskey() handler:

<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>

As you've already seen, the PasskeySubmit component, handles the registration of a passkey client-side, and then stores the details about the passkey in the surrounding form. The AddPasskey() method must then use this form data to actually save and persist the passkey details. This method is shown below with comments (with some error handling elided for brevity):

private async Task AddPasskey()
{
    // Retrieves details from the HttpContext that were stored when 
    // ConfigurePasskeyCreationOptionsAsync/MakePasskeyCreationOptionsAsync was called
    var options = await SignInManager.RetrievePasskeyCreationOptionsAsync();

    // Verify that the provided credentials are valid and can be saved
    var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson, options);
    if (!attestationResult.Succeeded)
    {
        RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}.", HttpContext);
        return;
    }

    // Save the results to the user account
    var setPasskeyResult = await UserManager.SetPasskeyAsync(user, attestationResult.Passkey);
    if (!setPasskeyResult.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 UserManager.SetPasskeyAsync() method is where the passkey is actually saved to the database, in a table called AspNetUserPasskeys. The initial definition for this table changed in a recent update, which is the version I show below. It's a simpler definition than the version used in preview 6, in that the Data column contains a JSON representation of the passkey credential details. The migration code shows that the database consists of just 2 ID columns and the Data column:

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

There's lots more code we could look at, some of which is pretty interesting, but seeing as this code is all changing rapidly in these preview releases, and this post is already long-enough as it is, I'll leave it there. Happy playing with passkeys!

Summary

In this post I gave a brief overview of Passkeys, and showed how basic passkey support has been added to ASP.NET Core Identity and the Blazor Web App template. I walked through the user process of adding a new passkey to an account, renaming it, and using it to log in. Finally I walked through the new code added to the template in .NET 10 preview 6. Much of this code has already changed in newer previews, but the overall flow and the interaction between the components remains the same.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?