Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Passkeys and WebAuthn with Duende IdentityServer and User Management

Maarten Balliauw
Two blue circles
Summary:
Duende User Management provides built-in WebAuthn/FIDO2 passkey support, including ceremony endpoints, credential storage, and a JavaScript helper, so you don't need a separate library.
• A progressive onboarding pattern (OTP first, then prompt passkey registration) lets you introduce passkeys without forcing users to change their login habits overnight.
• Passkeys are cryptographically bound to your domain (Relying Party ID), so plan your production URL before anyone registers a passkey, because changing it later invalidates all existing credentials.

We all use passwords, yet we all struggle to remember them. Some people reuse the same password across multiple websites. Others turn to password managers to create unique passwords for every site. Even with excellent personal security hygiene, data breaches and password-related attacks dominate cybersecurity headlines. Sites like HaveIBeenPwned show just how frequently our credentials are compromised.

The industry has tried multi-factor authentication, hardware security keys, and various other approaches. They help, but add friction and complexity. Passkeys are different: they're stronger than passwords, simpler for users, and resistant to phishing by design. And if you're using Duende User Management with IdentityServer, most of the implementation work is already done for you.

I wrote a series on passkeys that covers the protocol fundamentals and walks through an implementation using ASP.NET Core Identity as the user store. This post takes a different angle: if you're using Duende User Management, passkey support is built in, and there's a lot less code to write.

How Passkeys Work

If you've read the earlier series, skip ahead. If not, here's the short version.

Passkeys work using public key cryptography. Your device creates a pair of keys: one private, one public. When you sign up for a service, your device keeps the private key safe and only shares the public key with the service. When you log in, the service sends a random challenge to your device. Your device signs it with the private key, and the service checks the signature using the public key. This proves your identity without ever sending the private key over the internet.

Passkeys are bound to the service domain (called the Relying Party ID), so even if a phishing site tries to trick you, your device won't sign a challenge for the wrong site. Every login challenge is unique and only valid for a short time, preventing replay attacks. Because the private key never leaves the device, server breaches don't expose usable credentials either.

sequenceDiagram
    actor User
    participant Browser
    participant Server

    User->>Server: Navigate to login page
    Server->>Browser: Challenge (random, single-use)
    Browser->>User: Prompt (biometric / PIN)
    User->>Browser: Authenticate
    Browser->>Server: Signed challenge (private key never sent)
    Server->>Server: Verify signature with stored public key
    Server->>User: Signed in

The user experience is a fingerprint scan, a face unlock, or a PIN, using the authenticator built into the device (Touch ID, Windows Hello, Face ID) or a cross-platform authenticator like a YubiKey. Platform authenticators and password managers like 1Password, Bitwarden, and Dashlane synchronize passkeys across devices (through iCloud Keychain, Google Password Manager, or their own vaults), so losing a single device doesn't mean losing access.

The protocol behind this is WebAuthn, part of the broader FIDO2 standard. Browser support is universal: Chrome, Safari, Firefox, and Edge all support it across Windows, macOS, iOS, and Android.

Setting Up Passkeys with User Management

Duende User Management ships with passkey support built in: registration and authentication ceremonies, cryptographic verification, credential storage, challenge management, and a JavaScript helper for the browser side.

Service Registration

If you already have User Management wired up, enabling passkeys is a configuration change. If not, the Getting Started guide walks you through setting up User Management from scratch.

Csharp

builder.Services
    .AddIdentityServer()
    .AddUserManagement(um => um
        .Authentication(auth =>
        {
            auth.Configure(options =>
            {
                options.Passkeys.RelyingPartyName = "My Application";
                options.Passkeys.ServerDomain = "example.com";
                options.Passkeys.AllowedOrigins = ["https://auth.example.com"];
            });
        })
    );

var app = builder.Build();
app.MapUserManagement();

AddUserManagement() registers the core passkey services (signature verification, ceremony handling, credential storage). MapUserManagement() maps the HTTP endpoints that drive the WebAuthn ceremonies. The important configuration options are:

  • RelyingPartyName: the human-readable name shown to users during registration (like "My Application").
  • ServerDomain: the relying party ID. Set this if you want passkeys registered at auth.example.com to also work at app.example.com (by setting it to example.com).
  • AllowedOrigins: the exact origins permitted to use passkeys. Keep this tight. An overly broad list weakens the origin binding that makes passkeys phishing-resistant in the first place.

That's the server side. The endpoints User Management maps (via MapUserManagement()):

Endpoint Method Path

Begin Registration

POST

/passkeys/register/begin

Complete Registration

POST

/passkeys/register/complete

Begin Discoverable Auth

POST

/passkeys/authenticate/discoverable/begin

Complete Auth

POST

/passkeys/authenticate/complete

JavaScript Helper

GET

/passkeys/js

The Login Page

For discoverable passkey login (the "just tap your fingerprint" flow where the user doesn't even type a username), the browser-side code uses the built-in JavaScript helper:

Html

<script src="/passkeys/js" asp-append-version="true"></script>

<button id="passkey-login">Sign in with a passkey</button>

<script>
    document.getElementById("passkey-login").addEventListener("click", async function () {
        this.disabled = true;
        try {
            await authenticateWithDiscoverablePasskey();
            window.location.href = "/";
        } catch (err) {
            alert(err?.message ?? "Passkey authentication failed.");
            this.disabled = false;
        }
    });
</script>

The authenticateWithDiscoverablePasskey function (served from /passkeys/js) handles the full ceremony: it calls the begin endpoint, invokes navigator.credentials.get(), posts the result to the complete endpoint, and returns when the user is signed in. You don't touch the WebAuthn API directly.

The user clicks the button, their browser shows the passkey picker (Touch ID, Windows Hello, or whatever the platform provides), and they're signed in without needing a username or password.

Registering a Passkey

Passkey registration happens when a user is already signed in (they need an account first). In a "manage your security settings" page, you'd show the user's existing passkeys and let them add new ones or remove old ones.

The Razor view below does three things: it loops over the user's registered passkeys and displays each one with a remove button, it provides a text field where the user can name a new passkey (like "Work laptop" or "iPhone"), and it wires up the registerPasskey JavaScript helper to handle the WebAuthn registration ceremony when the user clicks "Add a passkey":

Html

<script src="/passkeys/js" asp-append-version="true"></script>

<h2>Passkeys</h2>

<div id="passkey-list">
    @foreach (var passkey in Model.RegisteredPasskeys)
    {
        <div>
            @passkey.Name (registered @passkey.CreatedAt.ToString("d"))
            <form method="post" asp-page-handler="RemovePasskey">
                <input type="hidden" name="credentialIdBase64"
                       value="@Convert.ToBase64String(passkey.CredentialId.Value)" />
                <button type="submit">Remove</button>
            </form>
        </div>
    }
</div>

<input type="text" id="passkey-name" placeholder="e.g. Work laptop" />
<button id="add-passkey">Add a passkey</button>

<script>
    document.getElementById("add-passkey").addEventListener("click", async function () {
        const name = document.getElementById("passkey-name").value || "My passkey";
        this.disabled = true;
        try {
            await registerPasskey(name);
            window.location.reload();
        } catch (err) {
            alert(err?.message ?? "Passkey registration failed.");
            this.disabled = false;
        }
    });
</script>

The registerPasskey function (from /passkeys/js) takes care of the registration ceremony: it calls /passkeys/register/begin to get a challenge and creation options from the server, passes those to navigator.credentials.create() which triggers the browser's authenticator prompt, and posts the result back to /passkeys/register/complete where User Management validates the attestation and stores the credential. All the Base64URL encoding and WebAuthn plumbing stays inside the helper.

The page model behind this uses IUserAuthenticatorsSelfService to load and manage credentials. On GET, it fetches the current user's authenticators and exposes their passkeys. The remove handler takes the Base64-encoded credential ID from the hidden form field and calls TryRemovePasskeyAsync:

Csharp

public class ManagePasskeysModel : PageModel
{
    private readonly IUserAuthenticatorsSelfService _selfService;

    public ManagePasskeysModel(IUserAuthenticatorsSelfService selfService)
    {
        _selfService = selfService;
    }

    public IReadOnlyCollection<UserPasskey> RegisteredPasskeys { get; private set; } = [];

    public async Task OnGetAsync(CancellationToken ct)
    {
        var userId = GetCurrentUserId();
        var authenticators = await _selfService.TryGetAsync(userId, ct);
        RegisteredPasskeys = authenticators?.Passkeys ?? [];
    }

    public async Task<IActionResult> OnPostRemovePasskeyAsync(
        string credentialIdBase64, CancellationToken ct)
    {
        var userId = GetCurrentUserId();
        var credentialId = PasskeyCredentialId.From(
            Convert.FromBase64String(credentialIdBase64));

        await _selfService.TryRemovePasskeyAsync(userId, credentialId, ct);
        return RedirectToPage();
    }

    private UserSubjectId GetCurrentUserId() =>
        UserSubjectId.Create(User.FindFirstValue(JwtClaimTypes.Subject)!);
}

That covers registration and management. You get ceremony handling, credential storage, and the browser-side JavaScript without pulling in a separate FIDO2 library or writing your own WebAuthn plumbing.

The OTP-to-Passkey Onboarding Flow

The Duende User Management FullSample on GitHub shows a pattern worth borrowing: use OTP as the entry point, then nudge users towards passkeys after their first sign-in.

The login page offers multiple options (passkey, OTP, password, Google), but the OTP path is the one to focus on here. A new user enters their email, receives a one-time code, and verifies it. User Management auto-registers the authenticator on first use, so the user now exists. The VerifyOtp page then checks whether this user already has a passkey registered, and if not, redirects them to a passkey registration prompt before completing the login.

flowchart LR
    A[Enter email] --> B[Receive OTP]
    B --> C[Verify code]
    C --> D{Has passkey?}
    D -- No --> E[Prompt: Register passkey]
    E --> F[Done]
    D -- Yes --> F
    E -. Skip .-> F

Here's the relevant part of the OTP verification page model. There's a fair bit going on, so let's walk through it.

The method starts by reading the OTP token and email address from an encrypted cookie that was set when the code was sent. It then verifies the one-time code against that token using IOtpAuthenticator. If this is a first-time user, OTP authentication auto-registers the authenticator, but it doesn't create a user profile, so the code checks for an existing profile and creates one if needed (using User Management's schema-driven attribute model to set the email address).

After that, the user is signed into IdentityServer with claims for their email and authentication method. The key bit comes at the end: the code checks whether this user already has any passkeys registered. If they don't, it redirects to the passkey registration page instead of sending them straight to their destination. If they do, it skips the prompt and continues normally.

Csharp

public async Task<IActionResult> OnPostAsync()
{
    if (!otpCookie.TryRead(out var token, out var emailAddress))
    {
        ErrorMessage = "OTP expired. Please sign in again.";
        return Page();
    }

    var otp = PlainTextOtp.Create(Code);
    if (await otpAuthenticator.TryAuthenticateAsync(
            otp, token.Value, HttpContext.RequestAborted)
        is not OtpAuthenticationResult.Success authResult)
    {
        ErrorMessage = "Invalid or expired verification code.";
        return Page();
    }

    otpCookie.Clear();
    var subjectId = authResult.UserSubjectId;

    // Ensure a user profile exists
    // (OTP auto-registers authenticators but not profiles)
    var existingProfile = await userSelfService.Profiles
        .TryGetAsync(subjectId, HttpContext.RequestAborted);
    if (existingProfile is null)
    {
        var schema = await userSelfService.Profiles
            .GetSchemaAsync(HttpContext.RequestAborted);
        var attributes = new AttributeValueCollection(schema);
        attributes.Set(
            OidcStandardAttributes.Email.Code, emailAddress.ToString());
        await userSelfService.Profiles.TryCreateAsync(
            subjectId, attributes.Validate(), HttpContext.RequestAborted);
    }

    // Sign the user in
    var identityServerUser = new IdentityServerUser(subjectId.ToString())
    {
        AdditionalClaims =
        [
            new Claim(JwtClaimTypes.Email,
                authResult.Address.SubjectId.ToString()),
            new Claim(JwtClaimTypes.AuthenticationMethod,
                OidcConstants.AuthenticationMethods.OneTimePassword)
        ]
    };

    await HttpContext.SignInAsync(identityServerUser,
        new AuthenticationProperties
        {
            IsPersistent = true,
            ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8),
        });

    // Check for existing passkeys; if none, prompt registration
    var authenticators = await userSelfService.Authenticators
        .TryGetAsync(subjectId, HttpContext.RequestAborted);
    var hasPasskey = authenticators?.Passkeys.Count > 0;

    var safeReturnUrl = Url.IsLocalUrl(ReturnUrl)
        ? ReturnUrl! : Url.Content("~/");

    if (!hasPasskey)
    {
        return RedirectToPage("/Account/RegisterPasskey",
            new { ReturnUrl = safeReturnUrl });
    }

    return LocalRedirect(safeReturnUrl);
}

The passkey registration prompt page is simple on purpose. It's a single button backed by the registerPasskey JavaScript helper, with a "Skip for now" link so users aren't forced into it. The user is already signed in at this point (the OTP verification took care of that), so the registration endpoints accept the request:

Html

<script src="/passkeys/js"></script>

<h2>Register a Passkey</h2>
<p>Secure your account by registering a passkey.
   You can use it to sign in quickly next time.</p>

<button id="register-btn" type="button">Register Passkey</button>
<a href="@Model.ReturnUrl">Skip for now</a>

<script>
    document.getElementById('register-btn')
        .addEventListener('click', async function () {
            this.disabled = true;
            try {
                await registerPasskey('@Model.UserName', '@Model.UserName');
                window.location.href =
                    @Html.Raw(Json.Serialize(Model.ReturnUrl));
            } catch (err) {
                alert(err?.message ?? 'Passkey registration failed.');
                this.disabled = false;
            }
        });
</script>

The page model behind this is minimal. The [Authorize] attribute ensures that only signed-in users can reach the page. The current user's name is read from their claims to pass to the registerPasskey function, which uses it as the credential's display name:

Csharp

[Authorize]
public sealed class RegisterPasskeyModel : PageModel
{
    public string ReturnUrl { get; private set; } = "/";
    public string UserName { get; private set; } = string.Empty;
    public string DisplayName { get; private set; } = string.Empty;

    public IActionResult OnGet(string? returnUrl)
    {
        ReturnUrl = Url.IsLocalUrl(returnUrl)
            ? returnUrl! : Url.Content("~/");
        UserName = User.FindFirst(JwtClaimTypes.Name)?.Value
            ?? User.FindFirst(ClaimTypes.Name)?.Value
            ?? User.Identity?.Name
            ?? User.FindFirst(JwtClaimTypes.Subject)?.Value
            ?? string.Empty;
        DisplayName = UserName;
        return Page();
    }
}

The result is a progressive flow where first-time users get in with just an email address (zero friction), then they're gently prompted to upgrade to passkeys for future logins. Returning users who already registered a passkey can skip OTP entirely and sign in with the "Sign in with Passkey" button on the main login page, using the discoverable credential flow we saw earlier.

The sample also shows a password + TOTP flow where passkeys serve as a second factor (users who have TOTP devices registered get redirected to a 2FA page after entering their password). Both paths converge on the same passkey infrastructure: authenticateWithDiscoverablePasskey for primary auth, authenticateWithPasskey for second-factor, and registerPasskey for onboarding. One set of endpoints, multiple authentication strategies.

Tip: The full sample is on GitHub at DuendeSoftware/samples and runs with .NET Aspire (including a Mailpit container for local email testing). It's worth cloning if you want to see all the pieces wired together: OTP, passwords, TOTP, passkeys, Google external login, and even ASP.NET Identity user migration.

Passkeys as a Second Factor

Not every application can go fully passwordless on day one. You might have users who still log in with a password, and you want to add passkeys as a step-up for sensitive operations or as an MFA requirement.

User Management supports this through the second-factor passkey flow. The user authenticates with their password (or OTP) first, then completes a passkey ceremony as the second factor.

You need to tell User Management how to find the partially-authenticated user. Implement ISecondFactorPasskeyAuthenticationResolver. The full sample uses an encrypted cookie to store the subject ID between the first and second factor:

Csharp

public class SecondFactorResolver(
    SecondFactorStateCookie stateCookie)
    : ISecondFactorPasskeyAuthenticationResolver
{
    public Task<UserSubjectId?> ResolveAsync(CancellationToken ct)
    {
        stateCookie.TryRead(out var subjectId);
        return Task.FromResult(subjectId);
    }
}

Register it:

Csharp

builder.Services
    .AddIdentityServer()
    .AddUserManagement(um => um
        .Authentication(auth =>
        {
            auth.EnablePasskeyForSecondFactor<SecondFactorResolver>();
        })
    );

And in your login flow, after the password succeeds, check if the user has a TOTP device (meaning they need a second factor), and store their identity for the resolver to pick up:

Csharp

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
    var result = await passwordAuthenticator.TryAuthenticateAsync(
        OidcStandardAttributes.Email,
        Email,
        NonValidatedPassword.Create(Password),
        ct);

    if (result is not PasswordAuthenticationResult.Success { UserSubjectId: var subjectId })
    {
        ErrorMessage = "Invalid username or password.";
        return Page();
    }

    var authenticators = await authenticatorsSelfService
        .TryGetAsync(subjectId, ct);

    if (authenticators?.TotpDeviceNames.Count > 0)
    {
        // Store the subject ID for the second-factor resolver
        secondFactorStateCookie.Write(subjectId);
        return RedirectToPage("/Account/LoginWith2FA",
            new { ReturnUrl });
    }

    // No 2FA configured, sign in directly
    var identityServerUser = new IdentityServerUser(subjectId.ToString())
    {
        AdditionalClaims =
        [
            new Claim(JwtClaimTypes.AuthenticationMethod,
                OidcConstants.AuthenticationMethods.Password)
        ]
    };

    await HttpContext.SignInAsync(identityServerUser,
        new AuthenticationProperties
        {
            IsPersistent = true,
            ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8),
        });

    return LocalRedirect(Url.IsLocalUrl(ReturnUrl)
        ? ReturnUrl! : Url.Content("~/"));
}

The /Account/LoginWith2FA page can then offer two options: enter a TOTP code from an authenticator app, or use a passkey as the second factor. The passkey path uses the authenticateWithPasskey JavaScript function (as opposed to authenticateWithDiscoverablePasskey). This one scopes the challenge to the specific user's registered credentials rather than presenting a browser-wide picker, because the resolver already knows who is authenticating.

The Tricky Parts

Passkeys are the strongest authentication option available in User Management, and the one with the fewest caveats. A few things to think about:

Account Recovery. This is a question I always get when speaking with people about passkeys. If a user loses their device and their passkeys were device-bound (not synced), they're locked out. Synced passkeys (iCloud Keychain, Google Password Manager) help a lot here since the credential survives a device loss. But you should still consider a fallback: recovery codes, OTP, or another authentication method. User Management supports all of these alongside passkeys.

Note that there is no "standard" or best-practice for recovery, and a lot will depend on your risk profile and internal processes. Recovery could be "come to our nearest office and bring your ID" when needed.

User Verification. The default UserVerificationRequirement is "preferred", which means authentication can succeed even if the authenticator skips the biometric or PIN check. For high-security scenarios (admin consoles, financial transactions), set it to "required":

Csharp

options.Passkeys.UserVerificationRequirement = "required";

Attestation. If you need to control which authenticator models your users can register (common in regulated environments), set AttestationConveyancePreference to "direct" and implement IAttestationTrustPolicy to allowlist specific AAGUIDs. The FIDO Metadata Service publishes AAGUIDs for well-known authenticator models.

Subdomain Scoping. A passkey registered at auth.example.com won't work at app.example.com unless you set ServerDomain to "example.com". This is the relying party ID, and getting it wrong means users have to register separate passkeys per subdomain. See Deployment Considerations below for more on this.

Deployment Considerations: Relying Party ID and Origins

One thing that's easy to get wrong with passkeys is the relationship between the Relying Party ID, the origin, and your deployment URLs. I covered this in a separate post on the Duende blog, but here's what matters for deployment.

A passkey credential is cryptographically bound to the Relying Party ID (which is a domain, like example.com) and the origin (the full URL the browser sees, like https://auth.example.com). The browser verifies the Relying Party ID against the current URL before it will offer a passkey, and the server verifies the origin in the signed response. This is the core anti-phishing property of WebAuthn.

The practical consequence: changing your application's URL invalidates all existing passkeys. Once a credential is created for a Relying Party ID, you can't move to a different domain without asking users to re-register.

A few things to plan for:

Subdomains. The browser allows a passkey registered at www.example.com to match a Relying Party ID of example.com (more specific origin, less specific RP ID). This is why ServerDomain in User Management lets you set it to the parent domain. But the reverse doesn't work: a passkey for example.com won't work if your RP ID is www.example.com.

Multi-Tenancy. If you use subdomains for tenants (tenant1.example.com, tenant2.example.com), setting ServerDomain = "example.com" means all tenants share the same Relying Party ID, and a passkey from one tenant could be used at another. For multi-tenant applications, a dedicated authentication endpoint on a specific subdomain (like login.example.com) is the safer approach.

Multiple Domains. If your application operates across multiple top-level domains (example.com, example.co.uk), you'll need Related Origin Requests (ROR), where a well-known endpoint at /.well-known/webauthn lists the valid origins. Or, use a central IdentityServer that handles passkey authentication on a single, stable URL and federates to all your applications via OpenID Connect.

Development vs. Production. The sample uses ServerDomain = "localhost" and AllowedOrigins = ["https://localhost:5001"]. These need to change for production. Plan your production URL before your first user registers a passkey, because migrating is not possible.

Csharp

um.Authentication(auth =>
{
    auth.Configure(opt =>
    {
        opt.Passkeys.ServerDomain = "example.com";
        opt.Passkeys.AllowedOrigins = ["https://auth.example.com"];
    });
});

Wrapping Up

Passkeys are the first password replacement that's both easier and more secure for users. Browser support is universal, platform syncing works, and the developer tooling is solid. If you're building a login flow in .NET today, there's a good reason to support them. And if you're already using Duende User Management, most of the plumbing is already there.

For more background on the protocol and a DIY implementation with ASP.NET Core Identity, see the passkey blog series. For the full API reference on everything covered here (attestation policies, custom sign-in handlers, second-factor configuration), see the User Management passkey documentation.

Related Articles