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

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
A
Arctic Wolf
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Threatpost
P
Proofpoint News Feed
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
www.infosecurity-magazine.com
www.infosecurity-magazine.com
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V
Vulnerabilities – Threatpost
V
V2EX
Webroot Blog
Webroot Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Privacy & Cybersecurity Law Blog
P
Privacy International News Feed
T
Tor Project blog
P
Proofpoint News Feed
T
Tailwind CSS Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Vercel News
Vercel News
Security Archives - TechRepublic
Security Archives - TechRepublic
MongoDB | Blog
MongoDB | Blog
T
Troy Hunt's Blog
Google DeepMind News
Google DeepMind News
NISL@THU
NISL@THU
C
CERT Recently Published Vulnerability Notes
W
WeLiveSecurity
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
GbyAI
GbyAI
Y
Y Combinator Blog
T
Threat Research - Cisco Blogs
S
Security Affairs
Google Online Security Blog
Google Online Security Blog
S
Securelist
Spread Privacy
Spread Privacy
Recent Announcements
Recent Announcements
The Register - Security
The Register - Security
C
Cybersecurity and Infrastructure Security Agency CISA
爱范儿
爱范儿
H
Help Net Security
Microsoft Security Blog
Microsoft Security Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Scott Helme
Scott Helme
The Cloudflare Blog
T
The Blog of Author Tim Ferriss
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
B
Blog RSS Feed
AWS News Blog
AWS News Blog

Duende Software Official Site

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 The 9 Components of SAML You Need to Know, Ranked by Importance The Cost of NOT Implementing Financial-Grade Security Token Issuer Isolation: Why It Matters for Security and Compliance The Composable Identity Pattern: Build What You Need, Skip What You Don't Multi-Brand Identity: When Your Company Needs More Than One Face Post-Quantum Cryptography in .NET 10: A Practical Guide The field Keyword in C# 14: Write Less, Validate More The Real Cost of Build vs. Buy for Identity OAuth 2.1 Made Simple: The Only Flows You Need Beyond localhost: Multi-Instance ASP.NET Core Deployment with .NET 10 Harden Your .NET JSON Deserialization with System.Text.Json and JsonSerializerOptions.Strict ASP.NET Core Cookie Size Limits in Production: Causes and Fixes The Emergency Stop Button - Implementing Immediate Token Revocation in .NET 10 The 2025 OWASP Top 10 and IdentityServer Update Guidance for CVE-2026-40372 - ASP.NET Data Protection Why a Standard JWT Access Token Matters The Identity Governance Checklist You Wish You Had Six Months Ago The History and Future of SAML: Why a 20-Year-Old Protocol Still Matters The Cookie Apocalypse Already Happened Verify - Open Source Sponsorship Why Identity Is Infrastructure, Not a Feature Extending Duende IdentityServer Server-Side Sessions with Dynamic User Metadata Give Your AI Coding Assistant Duende Expertise with Agent Skills and MCP Server Triggering User Registration via OpenID Connect with Duende IdentityServer Improving .NET Security Code with C# 14 Property Extensions Developing Audit Logs with Duende IdentityServer Events Patch Releases: Addressing CVE-2026-26127 in Microsoft.BCL.Memory Client-Initiated Backchannel Authentication (CIBA) in ASP.NET Core 10 with Duende Identity Server Rate Limiting IdentityServer Endpoints It's Probably DNS - Can You Dig It? Security Lingo Explained: Encode vs Encrypt vs Hash Implementing Zero Trust with Resource Isolation Security Lingo Explained: JWT DPoP Security for .NET APIs with JwtBearer Extensions v1.0.0 Announcing the Duende IdentityServer4 Migration Analysis Tool BenchmarkDotNet - Open Source Sponsorship Security Lingo Explained: PAR Why Signing Key Rotation Matters in OpenID Connect and Duende IdentityServer Security Lingo Explained: OP Duende Year-End Review 2025 Security Lingo Explained: BCP Security Lingo Explained: DPoP Security Lingo Explained: Auth Secure frontend apps with the BFF Pattern Scaling with Duende IdentityServer, MCP, and AI Duende IdentityServer v7.4 is now available Duende BFFv4 is now available Securing OpenAPI and Swagger UI with OAuth in .NET 10 Building a Federation Gateway with Duende IdentityServer: Strategies and Considerations for Identity Orchestration
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.