Stop Accepting Breached Passwords: Integrating HaveIBeenPwned with Duende UserManagement
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.MsSqlThe 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.
- Compute the SHA-1 hash of the password.
- Take the first 5 characters of the hex-encoded hash (the "prefix").
- Send only the prefix to the API.
- The API returns all hash suffixes in its database that begin with that prefix, along with breach counts.
- 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:
- Built-in character class rules: evaluated synchronously against
Passwordsconfig - 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.