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:
- An Introduction to Passkeys - The Future of Authentication
- Passkeys in .NET 10 Blazor Apps with ASP.NET Identity
- Deep-Dive Into Relying Party ID and Origin With Passkeys
- Adding .NET 10 Passkey Support to Duende IdentityServer (this post)
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:
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:
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.