惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - Franky
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
Vercel News
Vercel News
Recent Announcements
Recent Announcements
B
Blog RSS Feed
Y
Y Combinator Blog
M
MIT News - Artificial intelligence
MongoDB | Blog
MongoDB | Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
雷峰网
雷峰网
D
Docker
Jina AI
Jina AI
IT之家
IT之家
人人都是产品经理
人人都是产品经理
L
LangChain Blog
G
Google Developers Blog
Google DeepMind News
Google DeepMind News
MyScale Blog
MyScale Blog
博客园 - 叶小钗
The GitHub Blog
The GitHub Blog
The Cloudflare Blog
A
About on SuperTechFans
Hugging Face - Blog
Hugging Face - Blog

Duende Software Official Site

The Backend for Frontend Pattern Is Now Official IETF Guidance: RFC 10017 Published WhatsApp One-Time Password (OTP) Login with Duende IdentityServer and User Management Planning a Successful Migration from IdentityServer3 to Duende IdentityServer Client Secrets, Mutual TLS and Private Key JWT, Oh My! How To Spell "Duende" Understanding .NET 11 Automatic CSRF Protection: A Guide for Identity Developers Security Lingo Explained: TOTP (Time-based One-Time Password) Custom Passkey Attestation Policies: Restricting Login to Hardware Keys OAuth Identity Chaining, Transaction Tokens, and Human-in-the-Loop: Summer 2026 Identity Standards Recap What is Identity? - The Question Every Team Should Answer Before Writing Code Security Is a Spectrum: How to Choose Session Lifetimes in Duende IdentityServer Passkeys and WebAuthn with Duende IdentityServer and User Management Authenticating Players in Godot 4 with OAuth 2.0 and OpenID Connect Hardening OAuth in the newest 2026-07-28 MCP Release Candidate Unify Your SAML and OIDC Signing Keys with Automatic Rotation and Duende IdentityServer Duende Software Duende Software Duende Software Duende Software Duende Software Duende Software Duende Software Stop AI Bots from Wasting Your Server How Duende IdentityServer Filters Claims (And Why It Matters) Core vs Extended Protocols in Duende IdentityServer v8: What You Get and When You Need More Your IdentityServer v8 Upgrade Checklist: A Quick Pre-Flight Guide Setting Up SAML Single Sign-On in ASP.NET with Duende IdentityServer Your Identity, Your Terms: Duende's Modular Identity Infrastructure and v8.x Release Duende Spring Launch '26: Identity Infrastructure That Expands With You SAML and OpenID Connect (OIDC): Coexistence, Not Competition
Duende Software
AL Rodriguez, Khalid Abuhakmeh · 2026-07-22 · via Duende Software Official Site
Summary: Traditional password complexity requirements often fail to protect against credential stuffing attacks because they screen for format rather than for actual compromise. This guide demonstrates how to implement a more robust security model using Duende User Management. You will learn to integrate the "Have I Been Pwned" (HIBP) API to automatically reject known-compromised passwords during registration. By implementing a custom IPasswordValidator, you can prioritize real-world password hygiene, ensuring passwords have not appeared in historical data breaches, while preserving user privacy through k-anonymity. The resulting implementation provides clearer, actionable feedback to users and significantly strengthens your application's defense against automated credential attacks.

Defending against credential stuffing attacks is a critical component of modern IdentityServer best practices, yet many developers rely on outdated password policies. A password like Tr0ub4dor&3 passes every complexity rule you can think of: uppercase, lowercase, digits, and a symbol. But if it appeared in a single data breach three years ago, attackers have it in their lists right now. Your complexity checks never had a chance at stopping attackers from using compromised passwords.

Traditional password policies answer the wrong question. They ask, "Is this password complex?" when the more important question is, "Has this password been compromised before?" NIST SP 800-63B has been making this point since 2017: check new passwords against known-compromise lists, and stop treating character-class requirements as the primary defense.

We recently released Duende User Management, a commercial library for building user account management on top of IdentityServer. One of the things we wanted to get right from day one was the password validation story: a pipeline that runs built-in character-class checks and supports plugging in custom validators for exactly this kind of real-world hygiene check.

In this post, we'll show you how to write a custom password validator with one of the most widely used security APIs on the internet today, Have I Been Pwned (HIBP).

Installing Duende User Management

When working within an existing Duende IdentityServer project, you'll need to add the following package:

Shell

dotnet add package Duende.UserManagement.IdentityServer8

Duende.UserManagement.IdentityServer8 wires User Management into IdentityServer's DI container and authentication flows, which has the Duende.UserManagement package as a transitive dependency. Duende.UserManagement is the core library: it provides the password validation pipeline, user profile management, and the authenticator services used throughout this post.

You'll also need a storage backend. At the time of writing this post, Duende offers three first-party options:

Shell

# SQLite — no server required, good for local development
dotnet add package Duende.Storage.Sqlite

# PostgreSQL
dotnet add package Duende.Storage.PostgreSql

# SQL Server
dotnet add package Duende.Storage.MsSql

The examples in this post use SQLite. Pick whichever matches your target environment.

Built-in Password Validation Rules

Duende User Management ships with configurable text-based password rules. These run first, before any custom validator is invoked. You configure them through the Authentication builder inside AddUserManagement:

Csharp

.AddUserManagement(options =>
{
    options.AddSqliteStore(o =>
    {
        o.ConnectionString = "Data Source=usermanagement.db";
    });

    options.Authentication(cfg =>
    {
        cfg.Configure(c =>
        {
            c.Passwords.MinLower   = 1;
            c.Passwords.MinDigits  = 0;
            c.Passwords.MinSymbols = 0;
            c.Passwords.MinUpper   = 0;
        });
    });
});

The four options (MinLower, MinUpper, MinDigits, MinSymbols) set the minimum number of characters of each class that a password must contain. A value of 0 means the rule isn't enforced. If a password fails any active rule, validation stops immediately, and the errors are returned to the caller. Custom validators only run if the built-in rules pass.

A note on these settings: The configuration above intentionally relaxes the rules to a single lowercase character. The setting changes are done for demonstration purposes so that we can focus on custom validation behavior. For production systems, work with your security team to define requirements appropriate to your threat model. NIST 800-63B, for instance, no longer recommends mandatory character-class mixing. It recommends length and breach-list checks instead.

Why Password Complexity Rules Don't Catch Compromised Credentials

The problem with character class rules is that they screen for format but not for compromise.

P@ssw0rd1! satisfies nearly every complexity policy ever written. It has uppercase letters, lowercase letters, digits, and a symbol. It's 10 characters long. It is also one of the most common passwords in breach databases, appearing hundreds of thousands of times across known credential dumps.

NIST 800-63B Section 5.1.1.2 states this directly: verifiers shall compare the chosen secret against a list that contains values known to be commonly used, expected, or compromised. The list they're describing is exactly what HaveIBeenPwned provides: a database of over 10 billion real-world passwords harvested from data breaches, maintained by security researcher Troy Hunt.

Using this list at registration time means a password like P@ssw0rd1! gets rejected before it ever lands in your database, regardless of how well it scores on a complexity meter. That's a fundamentally stronger guarantee.

The IPasswordValidator Interface

Custom validators implement a single interface:

Csharp

public interface IPasswordValidator
{
    Task<PasswordValidationResult> ValidateAsync(
        UserSubjectId userId,
        string password,
        CancellationToken ct);
}

ValidateAsync returns either PasswordValidationResult.Accepted or PasswordValidationResult.Rejected with a reason string. Multiple implementations can be registered; they run in the order of DI registration, and the first rejection short-circuits the rest.

How the HaveIBeenPwned Passwords API Works

The HaveIBeenPwned Passwords API exposes an endpoint at https://api.pwnedpasswords.com/range/{prefix}. The design uses a technique called k-anonymity to ensure you never have to send a full password, or even a full hash, over the network.

  1. Compute the SHA-1 hash of the password.
  2. Take the first 5 characters of the hex-encoded hash (the "prefix").
  3. Send only the prefix to the API.
  4. The API returns all hash suffixes in its database that begin with that prefix, along with breach counts.
  5. Check whether your full hash suffix appears in the response.

Because the prefix occupies only 5 of the 40 hex characters, the server cannot reconstruct the original hash or password. You get the safety of a comprehensive breach database without leaking the credentials you're checking. The HIBP API is free, requires no authentication, and handles millions of requests per day.

Why SHA-1? SHA-1 is considered cryptographically weak, and you'll see linters flag it. In this context, that concern doesn't apply. We're not using SHA-1 to protect a secret; we're using it as a lookup key to query a specific API that was designed around SHA-1 hashes.

In the next section, we'll implement our own IPasswordValidator

Implementing a HaveIBeenPwned Password Validator

The implementation is relatively straightforward. When Duende User Management invokes our validator, we need to call the HIBP API. In our implementation, we have two important dependencies: IHttpClientFactory and HaveIBeenPwnedPasswordValidatorOptions. In a later section, we'll see how we register these elements with our .NET application's DI infrastructure.

Csharp

using System.Security.Cryptography;
using System.Text;
using Duende.UserManagement;
using Duende.UserManagement.Authentication;
using Duende.UserManagement.Authentication.Passwords;

namespace Unicorn.UserManagement.PasswordValidators;

/// <summary>
/// Checks passwords against the HaveIBeenPwned Passwords API v3
/// using k-anonymity (hash prefix query). The full password is never
/// transmitted over the network.
/// See https://haveibeenpwned.com/API/v3#PwnedPasswords
/// </summary>
public sealed class HaveIBeenPwnedPasswordValidator : IPasswordValidator
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly HaveIBeenPwnedPasswordValidatorOptions _options;

    public HaveIBeenPwnedPasswordValidator(
        IHttpClientFactory httpClientFactory,
        HaveIBeenPwnedPasswordValidatorOptions? options = null)
    {
        _httpClientFactory = httpClientFactory;
        _options = options ?? new HaveIBeenPwnedPasswordValidatorOptions();
    }

    public async Task<PasswordValidationResult> ValidateAsync(
        UserSubjectId userId,
        string password,
        CancellationToken ct)
    {
        var passwordBytes = Encoding.UTF8.GetBytes(password);

        // SHA-1 is required by the HIBP API — this is a lookup key, not a secret.
#pragma warning disable CA5350
        var hashBytes = SHA1.HashData(passwordBytes);
#pragma warning restore CA5350

        var hash   = Convert.ToHexString(hashBytes); // e.g. "5BAA61E4..."
        var prefix = hash[..5];
        var suffix = hash[5..];

        var httpClient = _httpClientFactory.CreateClient("hibp");
        try
        {
            using var response = await httpClient.GetAsync(
                new Uri($"https://api.pwnedpasswords.com/range/{prefix}"),
                ct);

            if (!response.IsSuccessStatusCode)
                return new PasswordValidationResult.Accepted();

            var body = await response.Content.ReadAsStringAsync(ct);

            // Each line: "<SUFFIX>:<count>"
            foreach (var line in body.AsSpan().EnumerateLines())
            {
                var colonIndex = line.IndexOf(':');
                if (colonIndex < 0) continue;

                var responseSuffix = line[..colonIndex];
                if (!responseSuffix.Equals(suffix.AsSpan(), StringComparison.OrdinalIgnoreCase))
                    continue;

                if (int.TryParse(line[(colonIndex + 1)..], out var count)
                    && count >= _options.MinimumBreachCount)
                {
                    return new PasswordValidationResult.Rejected(
                        "Password has appeared in a known data breach");
                }
            }

            return new PasswordValidationResult.Accepted();
        }
        catch (HttpRequestException)
        {
            // Fail open: a transient API outage should not block registration.
            return new PasswordValidationResult.Accepted();
        }
    }
}

public class HaveIBeenPwnedPasswordValidatorOptions
{
    /// <summary>
    /// Minimum number of breach appearances before a password is rejected.
    /// Defaults to 1 — reject if seen at all.
    /// </summary>
    public int MinimumBreachCount { get; init; } = 1;
}

MinimumBreachCount defaults to 1: reject the password if it has appeared in any known breach. Raise the threshold if you need to allow rarely seen passwords, though for most systems, 1 is correct.

Fail-open on errors: If the HIBP API is unreachable, the validator accepts the password and logs nothing; it doesn't block registration. The failsafe is a deliberate design choice: a transient outage shouldn't take down your registration flow. If your threat model requires blocking on API failure, that behavior is easy to invert.

Registering the Validator with Duende User Management

Register the validator and configure the named HttpClient in Program.cs:

Csharp

// IdentityServer registration code above 
.AddUserManagement(options =>
{
    options.AddSqliteStore(o =>
    {
        o.ConnectionString = "Data Source=usermanagement.db";
    });

    options.Authentication(cfg =>
    {
        cfg.Configure(c =>
        {
            c.Passwords.MinLower   = 1;
            c.Passwords.MinDigits  = 0;
            c.Passwords.MinSymbols = 0;
            c.Passwords.MinUpper   = 0;
        });
        cfg.AddHaveIBeenPwnedPasswordValidator();
    });
});

builder.Services.AddHttpClient("hibp", client =>
{
    client.DefaultRequestHeaders.UserAgent.ParseAdd("your-app/1.0");
});

The extension method AddHaveIBeenPwnedPasswordValidator lives alongside the validator:

Csharp

public static class HaveIBeenPwnedPasswordValidatorExtensions
{
    public static void AddHaveIBeenPwnedPasswordValidator(
        this IUserAuthenticationBuilder builder,
        HaveIBeenPwnedPasswordValidatorOptions? options = null)
    {
        builder.Services.AddTransient<IPasswordValidator, HaveIBeenPwnedPasswordValidator>(sp =>
        {
            var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
            return new HaveIBeenPwnedPasswordValidator(httpClientFactory, options);
        });
    }
}

The UserAgent header is required; the API will reject requests without one.

It's as simple as that. Now, whenever we want to set a password within Duende User Management, our validator will enforce our rules, including calling the HIBP.

Password Validator Execution Order

The pipeline runs like this:

  1. Built-in character class rules: evaluated synchronously against Passwords config
  2. Custom IPasswordValidator implementations: called in DI registration order; first rejection wins

If a password fails step 1, the custom validators never run. Short-circuiting is efficient: there's no point in hitting a network API for a password that's obviously too short. It also means you can have multiple custom validators. Say, a dictionary-word check followed by the HIBP check, in that order, and they'll execute as registered.

Example inputs with MinLower = 1 enforced:

Password Built-in Check HIBP Check Result

ABC123!

No lowercase

— skipped —

Rejected: missing lowercase

password

Passes

9.6M breaches

Rejected: known breach

hunter2

Passes

17K breaches

Rejected: known breach

correcthorsebatterystaple

Passes

Not found

Accepted

xkT9p-unique-random-value

Passes

Not found

Accepted

Remember, "password" isn't rejected for lack of complexity; it's rejected because 9.6 million people have used it, and attackers know it. A long passphrase like correcthorsebatterystaple gets through because it's genuinely rare in breach data, even without special characters.

Validating Passwords at User Registration

The validator runs when you call TryValidatePasswordAsync on IUserAuthenticatorsSelfService. Validation happens before the user record is created: validate first, then create, then set the password.

Csharp

public class RegisterModel(
    IUserProfileSelfService profileSelfService,
    IUserAuthenticatorsSelfService authenticatorsSelfService) : PageModel
{
    [BindProperty, Required, EmailAddress] public string? Email    { get; set; } = "";
    [BindProperty, Required]              public string? Password  { get; set; } = "";

    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
    {
        // Step 1: Validate password through the full pipeline
        var passwordResult = await authenticatorsSelfService.TryValidatePasswordAsync(
            UserSubjectId.New(),
            Password ?? "",
            ct);

        if (passwordResult is PasswordCreationResult.Failed failure)
        {
            foreach (var error in failure.Errors)
                ModelState.AddModelError(nameof(Password), error);
        }

        if (!ModelState.IsValid) return Page();

        // Step 2: Create the user profile
        var schema     = await profileSelfService.GetSchemaAsync(ct);
        var attributes = new AttributeValueCollection(schema);
        attributes.Set(AttributeCode.Create("email"), Email!.Trim());

        var profile = await profileSelfService.TryCreateAsync(
            UserSubjectId.New(),
            attributes.Validate(),
            ct);

        if (profile is null)
        {
            ModelState.AddModelError(nameof(Email),
                "Registration failed. Please contact support.");
            return Page();
        }

        // Step 3: Set the already-validated password
        var passwordSet = await authenticatorsSelfService.TrySetPasswordAsync(
            profile.SubjectId,
            ((PasswordCreationResult.Success)passwordResult).Password,
            ct);

        if (!passwordSet)
        {
            ModelState.AddModelError(nameof(Email),
                "Registration failed. Please contact support.");
            return Page();
        }

        return RedirectToPage("/Account/Login");
    }
}

When a breached password is submitted, the rejection message surfaces directly in the form:

Html

<label asp-for="Password">
    Password
    <input type="password"
           asp-for="Password"
           placeholder="Choose a strong password"
           aria-invalid="@(Model.HasAnyErrors(nameof(Model.Password)) ? "true" : null)"
           aria-describedby="password-helper" />
    <small id="password-helper">
        <span asp-validation-for="Password"></span>
    </small>
</label>

The user sees "Password has appeared in a known data breach" inline, next to the field, with full ARIA accessibility. No ambiguous "password doesn't meet requirements" message. The user knows exactly what happened and why.

Testing a Custom Password Validator

The test suite makes real HTTP calls to the HIBP API, with no mocking required. The API is free, public, and rate-limit friendly for test volumes. A minimal IHttpClientFactory wrapper around new HttpClient() is all the infrastructure you need:

Csharp

public class HaveIBeenPwnedPasswordValidatorTests(ITestOutputHelper output)
{
    private sealed class RealHttpClientFactory : IHttpClientFactory
    {
        public HttpClient CreateClient(string name) => new();
    }

    private static readonly IHttpClientFactory Factory  = new RealHttpClientFactory();
    private static readonly UserSubjectId       AnyUser = UserSubjectId.Create("test-user");

    [Fact]
    public async Task ValidateAsync_WithKnownBreachedPassword_RejectsPassword()
    {
        // "password" appears millions of times in known data breaches.
        var validator = new HaveIBeenPwnedPasswordValidator(Factory);

        var result = await validator.ValidateAsync(AnyUser, "password", CancellationToken.None);

        Assert.IsType<PasswordValidationResult.Rejected>(result);
    }

    [Fact]
    public async Task ValidateAsync_WithUniqueRandomPassword_AcceptsPassword()
    {
        // A double-GUID password has never appeared in any breach dataset.
        var validator      = new HaveIBeenPwnedPasswordValidator(Factory);
        var uniquePassword = $"UniqueP@ssw0rd-{Guid.NewGuid():N}-{Guid.NewGuid():N}";

        var result = await validator.ValidateAsync(AnyUser, uniquePassword, CancellationToken.None);

        Assert.IsType<PasswordValidationResult.Accepted>(result);
    }

    [Fact]
    public async Task ValidateAsync_WhenBreachCountBelowMinimum_AcceptsPassword()
    {
        var validator = new HaveIBeenPwnedPasswordValidator(
            Factory,
            new HaveIBeenPwnedPasswordValidatorOptions { MinimumBreachCount = int.MaxValue });

        var result = await validator.ValidateAsync(AnyUser, "password", CancellationToken.None);

        Assert.IsType<PasswordValidationResult.Accepted>(result);
    }
}

The double-GUID pattern guarantees a password that has never existed in any breach dataset, so there's no need to maintain a fixture of "known-clean" passwords that might someday appear in new leaks. The MinimumBreachCount = int.MaxValue test proves the threshold logic works independently of the API response.

Wrapping Up

When modernizing your password validation strategies, remember that complexity rules are always the wrong question. They describe what a password looks like, not whether it's safe to use. The IPasswordValidator pipeline in Duende User Management lets you answer the right question: has this credential been seen in the wild?

The HIBP integration here is ~80 lines, privacy-preserving by design, and composable with any other validators you register alongside it. If your threat model requires more than breach checking, add another IPasswordValidator; it runs next in line.

If you want to dig deeper into what Duende User Management can do beyond password validation, including registration flows, profile management, and multi-factor authentication, the documentation is the place to start. And if you're evaluating whether it fits your project, duendesoftware.com has licensing details and a full overview of the product lineup.

Built something interesting on top of this pattern? Have a question about the implementation? Drop a comment below, and we’re happy to help.