Adding .NET 10 Passkey Support to Duende IdentityServer

Maarten Balliauw |

In recent posts, we have looked at passkey authentication. We saw that passkeys are more secure and phishing-resistant than traditional username and password authentication, thanks to the use of public key cryptography. We also saw how the .NET 10 Blazor project templates add passkey authentication in projects with ASP.NET Identity.

You can also add passkey support to existing ASP.NET Core and Razor Pages projects. In this post, we'll take a practical approach and see how to add .NET passkey support to Duende IdentityServer through ASP.NET Identity.

Other posts in this series:

Creating a Duende IdentityServer with ASP.NET Identity

You can find the code for this blog post in our samples repository on GitHub. If you prefer to follow along and start from scratch, you can!

To start, let's create a new Duende IdentityServer with ASP.NET Identity as the user store. You'll need the Duende project templates installed, after which you can create a new project with ASP.NET Identity:

dotnet new install Duende.Templates
dotnet new duende-is-aspid -n IdentityServerAspNetIdentityPasskeys

Also, make sure the Entity Framework Core tools (for .NET 10) are available. You can install them globally, or use a tools manifest for your project:

dotnet new tool-manifest
dotnet tool install dotnet-ef --prerelease

At the time of writing, the IdentityServer project templates do not yet target .NET 10 out of the box. Let's update a few things.
In the newly created IdentityServerAspNetIdentityPasskeys.csproj, update the TargetFramework element to net10.0, and bump the package references to the .NET 10 versions as well:

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Duende.IdentityServer.AspNetIdentity" version="7.3.1" />
        <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />

        <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
        <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
        <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.0-rc.2.25502.107" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0-rc.2.25502.107" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0-rc.2.25502.107">
          <PrivateAssets>all</PrivateAssets>
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
    </ItemGroup>
</Project>

Great! You now have a Duende IdentityServer backed by ASP.NET Identity targeting .NET 10. Let's see what we need to add passkey support.

Add a Database Migration for the Latest ASP.NET Identity Schema

When we discussed passkeys in .NET 10 Blazor Apps with ASP.NET Identity, we looked at how ASP.NET Identity requires a new database schema version that adds a new AspNetUserPasskeys table to store a passkey's credential ID, user ID, and passkey data.

To add passkey support to an existing project, you will need to configure the schema version and add a new Entity Framework Core migration.

In your Duende IdentityServer project, you’ll find ASP.NET Identity is added in the HostingExtensions.cs file:

builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

The AddIdentity method supports adding a delegate to provide configuration details, where you can set the database schema version to IdentitySchemaVersions.Version3:

builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
    {
        options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

As a final step, you can create a new database migration and apply it to your application database. Run the following from a command prompt in your project directory:

dotnet ef migrations add IdentitySchemaVersion3
dotnet ef database update

If you're starting with an empty database, you can run the /seed command from the command line to add the default alice and bob users (both with Pass123$ as their passwords):

dotnet run --project IdentityServerAspNetIdentityPasskeys -- /seed

With the database and updated schema in place, you're ready to start adding passkey support to the Razor Pages UI that was created by the project template for your Duende IdentityServer.

Passkey Endpoints

In our previous post about passkeys in .NET 10 Blazor Apps, we saw the base infrastructure required to implement passkeys in an ASP.NET Core application. To support creating and authenticating with a passkey credential, your Duende IdentityServer will need two HTTP APIs to be available: /Identity/Account/PasskeyCreationOptions and /Identity/Account/PasskeyRequestOptions.

In your project, add a new PasskeyEndpointRouteBuilderExtensions class that contains a copy of the relevant passkey-related APIs from the .NET 10 Blazor App template:

using IdentityServerAspNetIdentityPasskeys.Models;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace IdentityServerAspNetIdentityPasskeys.Passkeys;

public static class PasskeyEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapPasskeyEndpoints(this IEndpointRouteBuilder endpoints)
    {
        ArgumentNullException.ThrowIfNull(endpoints);

        var accountGroup = endpoints.MapGroup("/Identity/Account").ExcludeFromDescription();

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

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

            var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
            var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
            return TypedResults.Content(optionsJson, contentType: "application/json");
        });

        return accountGroup;
    }
}

To make these two endpoints available, make sure to add them to your ASP.NET Core pipeline. In the ConfigurePipeline method in HostingExtensions.cs, call the extension method you just created:

public static WebApplication ConfigurePipeline(this WebApplication app)
{
    // ...

    app.UseIdentityServer();
    app.UseAuthorization();

    app.MapPasskeyEndpoints();

    app.MapRazorPages()
        .RequireAuthorization();

    // ...
}

We're now one step closer to adding passkey support in Duende IdentityServer. Let's keep going!

The passkey-submit Web Component and Tag Helper

In the .NET 10 Blazor Apps project template, there is a PasskeySubmit Razor component (and an associated JavaScript file) to help render a passkey registration or login button.

This component renders a submit button (named __passkeySubmit) to help the server recognize passkey registration or login requests, and a passkey-submit web component that adds required event handlers to perform passkey operations in the browser.

The Blazor component utilizes a feature not available in Razor Pages: copying HTML attributes to child elements of the component. Luckily, Razor Pages support tag helpers to achieve the same!

Let's start by defining a PasskeyOperation enum to distinguish between creating a new passkey or requesting an already registered passkey from the browser. Add the following enum definition to your project:

public enum PasskeyOperation
{
    Create = 0,
    Request = 1,
}

Next, add a PasskeySubmitTagHelper class to help render the passkey registration/login button. The tag helper will be triggered when a <passkey-submit /> HTML element is used in Razor Pages, and supports an operation, name, and email-name attribute to assist client-side code in finding the necessary HTML input elements for user data.

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace IdentityServerAspNetIdentityPasskeys.Passkeys;

[HtmlTargetElement("passkey-submit")]
public class PasskeySubmitTagHelper : TagHelper
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    [HtmlAttributeName("operation")]
    public PasskeyOperation Operation { get; set; }

    [HtmlAttributeName("name")]
    public string Name { get; set; } = null!;
    
    [HtmlAttributeName("email-name")]
    public string? EmailName { get; set; }

    public PasskeySubmitTagHelper(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        // Get tokens
        var tokens = _httpContextAccessor.HttpContext?.RequestServices
            .GetService<IAntiforgery>()?.GetTokens(_httpContextAccessor.HttpContext);
        
        // Button is the main element we want to create, capture all attributes etc.
        var buttonAttributes = output.Attributes.Where(it => it.Name != "operation" && it.Name != "name" && it.Name != "email-name").ToList();
        var buttonContent = (await output.GetChildContentAsync(NullHtmlEncoder.Default))
            .GetContent(NullHtmlEncoder.Default);
        
        // Create the button
        using var htmlWriter = new StringWriter();
        htmlWriter.Write("<button type=\"submit\" name=\"__passkeySubmit\" ");
        foreach (var buttonAttribute in buttonAttributes)
        {
            buttonAttribute.WriteTo(htmlWriter, NullHtmlEncoder.Default);
            htmlWriter.Write(" ");
        }
        htmlWriter.Write(">");
        if (!string.IsNullOrEmpty(buttonContent))
        {
            htmlWriter.Write(buttonContent);
        }
        htmlWriter.Write("</button>");
        htmlWriter.WriteLine();
        
        // Create the element
        htmlWriter.Write("<passkey-submit ");
        htmlWriter.Write($"operation=\"{Operation}\" ");
        htmlWriter.Write($"name=\"{Name}\" ");
        htmlWriter.Write($"email-name=\"{EmailName ?? ""}\" ");
        htmlWriter.Write($"request-token-name=\"{tokens?.HeaderName ?? ""}\" ");
        htmlWriter.Write($"request-token-value=\"{tokens?.RequestToken ?? ""}\" ");
        htmlWriter.Write(">");
        htmlWriter.Write("</passkey-submit>");
        
        // Emit the element
        output.TagName = null;
        output.Attributes.Clear();
        output.Content.Clear();
        output.Content.SetHtmlContent(htmlWriter.ToString());

        await base.ProcessAsync(context, output);
    }
}

The PasskeySubmitTagHelper renders a submit button (and copies any HTML attributes so you can style it with CSS classes), and then writes a <passkey-submit /> element on the page that will be enhanced with web component code.

In your project's wwwroot/js folder, add a passkey-submit.js file and copy over the contents from the .NET 10 Blazor App project template's Components/Account/Shared/PasskeySubmit.razor.js file:

const browserSupportsPasskeys =
    typeof navigator.credentials !== 'undefined' &&
    typeof window.PublicKeyCredential !== 'undefined' &&
    typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' &&
    typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function';

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

async function createCredential(headers, signal) {
    const optionsResponse = await fetchWithErrorHandling('/Identity/Account/PasskeyCreationOptions', {
        method: 'POST',
        headers,
        signal,
    });
    const optionsJson = await optionsResponse.json();
    const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
    return await navigator.credentials.create({ publicKey: options, signal });
}

async function requestCredential(email, mediation, headers, signal) {
    const optionsResponse = await fetchWithErrorHandling(`/Identity/Account/PasskeyRequestOptions?username=${email}`, {
        method: 'POST',
        headers,
        signal,
    });
    const optionsJson = await optionsResponse.json();
    const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
    return await navigator.credentials.get({ publicKey: options, mediation, signal });
}

customElements.define('passkey-submit', class extends HTMLElement {
    static formAssociated = true;

    connectedCallback() {
        this.internals = this.attachInternals();
        this.attrs = {
            operation: this.getAttribute('operation'),
            name: this.getAttribute('name'),
            emailName: this.getAttribute('email-name'),
            requestTokenName: this.getAttribute('request-token-name'),
            requestTokenValue: this.getAttribute('request-token-value'),
        };

        this.internals.form.addEventListener('submit', (event) => {
            if (event.submitter?.name === '__passkeySubmit') {
                event.preventDefault();
                this.obtainAndSubmitCredential();
            }
        });

        this.tryAutofillPasskey();
    }

    disconnectedCallback() {
        this.abortController?.abort();
    }

    async obtainCredential(useConditionalMediation, signal) {
        if (!browserSupportsPasskeys) {
            throw new Error('Some passkey features are missing. Please update your browser.');
        }

        const headers = {
            [this.attrs.requestTokenName]: this.attrs.requestTokenValue,
        };

        if (this.attrs.operation === 'Create') {
            return await createCredential(headers, signal);
        } else if (this.attrs.operation === 'Request') {
            const email = new FormData(this.internals.form).get(this.attrs.emailName);
            const mediation = useConditionalMediation ? 'conditional' : undefined;
            return await requestCredential(email, mediation, headers, signal);
        } else {
            throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`);
        }
    }

    async obtainAndSubmitCredential(useConditionalMediation = false) {
        this.abortController?.abort();
        this.abortController = new AbortController();
        const signal = this.abortController.signal;
        const formData = new FormData();
        try {
            const credential = await this.obtainCredential(useConditionalMediation, signal);
            const credentialJson = JSON.stringify(credential);
            formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
        } catch (error) {
            if (error.name === 'AbortError') {
                // The user explicitly canceled the operation - return without error.
                return;
            }
            console.error(error);
            if (useConditionalMediation) {
                // An error occurred during conditional mediation, which is not user-initiated.
                // We log the error in the console but do not relay it to the user.
                return;
            }
            const errorMessage = error.name === 'NotAllowedError'
                ? 'No passkey was provided by the authenticator.'
                : error.message;
            formData.append(`${this.attrs.name}.Error`, errorMessage);
        }
        this.internals.setFormValue(formData);
        this.internals.form.submit();
    }

    async tryAutofillPasskey() {
        if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) {
            await this.obtainAndSubmitCredential(/* useConditionalMediation */ true);
        }
    }
});

This script currently does not support password managers like 1Password. Use this replacement that adds support for password managers by using custom JSON serialization of the passkey credential created by the password manager.

One final step before you can start using the passkey-submit tag helper and web component: you'll need to register it! In your Duende IdentityServer's Pages/_ViewImports.cshtml, add your project's assembly name to register tag helpers:

@using IdentityServerAspNetIdentityPasskeys.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, IdentityServerAspNetIdentityPasskeys

Duende IdentityServer Login Page with Passkey Support

To add passkey authentication to your project, you'll need a few updates to the login page. Let's start with the Razor view. In Pages/Account/Login/Index.cshtml, add the following code just before the end of the login </form>, under the cancel button:

<hr />

<passkey-submit operation="@PasskeyOperation.Request" name="Input.Passkey" email-name="Input.Username" class="btn btn-outline-primary position-relative" formnovalidate>
    Log in with a passkey
</passkey-submit>

At the bottom of the Razor view, reference the passkey-submit.js script you created earlier:

@section scripts
{
    <script src="~/js/passkey-submit.js" asp-append-version="true"></script>
}

The passkey-submit tag helper will render the necessary HTML elements to authenticate with a passkey. The name="Input.Passkey" attribute is used by the web component to target the form field in which to submit the passkey credential as JSON. In your project, create a new class that can contain passkey credential JSON:

public class PasskeyInputModel
{
    public string? CredentialJson { get; set; }
    public string? Error { get; set; }
}

Next, make sure the login page's InputModel contains a Passkey property using this PasskeyInputModel class:

public class InputModel
{
    // ...

    public PasskeyInputModel? Passkey { get; set; }
}

Next, you'll need to update the login page logic to support passkeys. All changes will be implemented in the OnPost() page handler of Pages/Account/Login/Index.cshtml.cs. We'll look at individual pieces of code, and then end this section with the complete code of OnPost().

The OnPost() handler checks which button the user clicked early on. When any button other than login is clicked, the page cancels the login attempt. The passkey-submit tag helper added a _passkeySubmit button, which we'll want to allow as well. Update this check to also support the passkey submit button:

// the user clicked the "cancel" button
if (Input.Button != "login" && Input.Button != "__passkeySubmit" && Input.Button != null)

Next, replace the check for ModelState.IsValid, and see if the passkey credential JSON is available. If it is, you'll want to clear the model state (we don’t want to show form errors when there is a missing username/password but there is a passkey credential). The credential JSON can also be used to perform passkey sign in.

SignInResult? result = null;
ApplicationUser? user = null;
if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson))
{
    // When performing passkey sign-in, don't perform form validation.
    ModelState.Clear();

    result = await _signInManager.PasskeySignInAsync(Input.Passkey.CredentialJson);
    if (result.Succeeded)
    {
        user = await _userManager.GetUserAsync(User);
    }
}
else if (ModelState.IsValid)
{
    // Only remember login if allowed
    var rememberLogin = LoginOptions.AllowRememberLogin && Input.RememberLogin;

    result = await _signInManager.PasswordSignInAsync(Input.Username!, Input.Password!, isPersistent: rememberLogin, lockoutOnFailure: true);
    if (result.Succeeded)
    {
        user = await _userManager.FindByNameAsync(Input.Username!);
    }
}

The rest of the code remains roughly the same. Whether you use passkey authentication or username/password, the IdentityServer code needs the newly signed-in user to be known to be able to raise a successful login event and continue the OpenID Connect authentication process.

For reference, the resulting OnPost() handler will now look like this:

public async Task<IActionResult> OnPost()
{
    // check if we are in the context of an authorization request
    var context = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);

    // the user clicked the "cancel" button
    if (Input.Button != "login" && Input.Button != "__passkeySubmit" && Input.Button != null)
    {
        if (context != null)
        {
            // This "can't happen", because if the ReturnUrl was null, then the context would be null
            ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));

            // if the user cancels, send a result back into IdentityServer as if they
            // denied the consent (even if this client does not require consent).
            // this will send back an access denied OIDC error response to the client.
            await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);

            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
            if (context.IsNativeClient())
            {
                // The client is native, so this change in how to
                // return the response is for better UX for the end user.
                return this.LoadingPage(Input.ReturnUrl);
            }

            return Redirect(Input.ReturnUrl ?? "~/");
        }
        else
        {
            // since we don't have a valid context, then we just go back to the home page
            return Redirect("~/");
        }
    }

    SignInResult? result = null;
    ApplicationUser? user = null;
    if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson))
    {
        // When performing passkey sign-in, don't perform form validation.
        ModelState.Clear();

        result = await _signInManager.PasskeySignInAsync(Input.Passkey.CredentialJson);
        if (result.Succeeded)
        {
            user = await _userManager.GetUserAsync(User);
        }
    }
    else if (ModelState.IsValid)
    {
        // Only remember login if allowed
        var rememberLogin = LoginOptions.AllowRememberLogin && Input.RememberLogin;

        result = await _signInManager.PasswordSignInAsync(Input.Username!, Input.Password!, isPersistent: rememberLogin, lockoutOnFailure: true);
        if (result.Succeeded)
        {
            user = await _userManager.FindByNameAsync(Input.Username!);
        }
    }

    if (result?.Succeeded == true && user != null)
    {
        await _events.RaiseAsync(new UserLoginSuccessEvent(user!.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
        Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider);

        if (context != null)
        {
            // This "can't happen", because if the ReturnUrl was null, then the context would be null
            ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));

            if (context.IsNativeClient())
            {
                // The client is native, so this change in how to
                // return the response is for better UX for the end user.
                return this.LoadingPage(Input.ReturnUrl);
            }

            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
            return Redirect(Input.ReturnUrl ?? "~/");
        }

        // request for a local page
        if (Url.IsLocalUrl(Input.ReturnUrl))
        {
            return Redirect(Input.ReturnUrl);
        }
        else if (string.IsNullOrEmpty(Input.ReturnUrl))
        {
            return Redirect("~/");
        }
        else
        {
            // user might have clicked on a malicious link - should be logged
            throw new ArgumentException("invalid return URL");
        }
    }

    const string error = "invalid credentials";
    await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId: context?.Client.ClientId));
    Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error);
    ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage);

    // something went wrong, show form with error
    await BuildModelAsync(Input.ReturnUrl);
    return Page();
}

You can now log in to your Duende IdentityServer with a passkey! At least, the UI is there. But… Aren't we missing something?

Register a Passkey with Duende IdentityServer

In the previous section, the login page was updated to allow logging in with a passkey. Since your users haven't been able to register a passkey yet, let's fix that!

You can copy the Passkeys.cshtml, Passkeys.cshtml.cs, RenamePasskey.cshtml, and RenamePasskey.cshtml.cs (and _StatusMessage.cshtml) from our samples repository. These were ported from the .NET 10 Blazor App project template.

The Passkeys.cshtml Razor page (and its model) also uses the passkey-submit tag helper to render a passkey registration page, and then perform passkey attestation before adding (or updating) the passkey persisted in the ASP.NET Identity database:

public async Task<IActionResult> OnPostAddPasskeyAsync()
{
    var user = await _userManager.GetUserAsync(User);
    // ... validation ...

    var attestationResult = await _signInManager.PerformPasskeyAttestationAsync(Input.Passkey.CredentialJson);
    if (!attestationResult.Succeeded)
    {
        StatusMessage = $"Could not add the passkey: {attestationResult.Failure.Message}.";
        return RedirectToPage();
    }

    var setPasskeyResult = await _userManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);
    if (!setPasskeyResult.Succeeded)
    {
        StatusMessage = "The passkey could not be added to your account.";
        return RedirectToPage();
    }

    // Immediately prompt the user to enter a name for the credential
    StatusMessage = "The passkey was added to your account. You can now use it to sign in. Give it an easy to remember name.";
    return RedirectToPage("./RenamePasskey", new { id = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId) });
}

Allowing Localhost Origin

As we saw in the previous blog post, the server Relying Party ID and browser origin matter when validating a passkey credential. At the time of writing, the passkey validation logic in .NET 10 does not work on localhost. In HostingExtensions.cs, you'll need to add IdentityPasskeyOptions and allow your local development environment as a supported origin to be able to run this sample on your own machine. Add the following code to the ConfigureServices method:

if (builder.Environment.IsDevelopment())
{
    builder.Services.Configure<IdentityPasskeyOptions>(options =>
    {
        // Allow https://localhost:5001 origin.
        options.ValidateOrigin = context => ValueTask.FromResult(
            context.Origin == "https://localhost:5001");
    });
}

That's it! You're now ready to run the Duende IdentityServer project.

Passkey Registration and Login UI

When you run the sample application, you'll see that the Duende IdentityServer login page now shows a “Login with a Passkey” button:

Passkey login in IdentityServer

After an initial login with username and password (try alice/Pass123$), you can navigate to https://localhost:5001/account/passkeys and add, rename and remove passkeys:

Manage passkey in IdentityServer

Congratulations, you have just added passkey support to Duende IdentityServer with ASP.NET Identity!

Conclusion

In previous posts, we covered in detail what passkeys are and how they work. In this post, we took a more practical approach and demonstrated how to integrate .NET 10 passkey support into Duende IdentityServer using ASP.NET Identity. We covered the necessary steps, from updating the project to .NET 10 and adding database migrations, to implementing the passkey endpoints and creating a custom tag helper for the UI.

We also updated the login page and added a passkey management page to a Duende IdentityServer project.

Check the full sample code on GitHub, and start adopting a more modern authentication method to your existing or new ASP.NET Core applications!

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