C# / .NET · Password breach checks
Check breached passwords in C# and .NET 8
This example wraps the password check in a PasswordBreachChecker class that takes an HttpClient through its primary constructor, the same shape you would register with dependency injection. It builds an HttpRequestMessage for /v1/breaches/password with JsonContent.Create and returns the count field as a long. Finowl hashes the password with SHA-1 and looks up only a five-character prefix, so the value is never stored, logged or returned.
The console host reads input with Console.ReadKey(true) so characters are not echoed, and every exception message describes the HTTP status rather than the input. That matters in .NET, where unhandled exceptions often end up in Application Insights or another log sink. In ASP.NET Core Identity, call the checker from a custom IPasswordValidator<TUser> and return an IdentityError when the count is above zero.
Steps
- 1
Paste the example into Program.cs in a new console project.
- 2
Export FINOWL_API_KEY, run dotnet run, then type a password at the masked prompt.
- 3
Register PasswordBreachChecker with AddHttpClient<PasswordBreachChecker>() in your web app.
- 4
Call GetBreachCountAsync from a custom IPasswordValidator<TUser> during registration and password changes.
- 5
Return an IdentityError that asks for a different password when the count is positive.
dotnet new console -n FinowlQuickstart
cd FinowlQuickstartusing System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var checker = new PasswordBreachChecker(http);
Console.Write("New password: ");
var password = ReadMasked();
var count = await checker.GetBreachCountAsync(password);
Console.WriteLine(count > 0
? $"Seen {count:N0} times in breach data. Choose another password."
: "Not found in known breach data.");
static string ReadMasked()
{
var sb = new StringBuilder();
for (var key = Console.ReadKey(true); key.Key != ConsoleKey.Enter; key = Console.ReadKey(true))
if (key.Key == ConsoleKey.Backspace && sb.Length > 0) sb.Length--;
else if (!char.IsControl(key.KeyChar)) sb.Append(key.KeyChar);
Console.WriteLine();
return sb.ToString();
}
sealed class PasswordBreachChecker(HttpClient http)
{
private readonly string _apiKey = Environment.GetEnvironmentVariable("FINOWL_API_KEY")
?? throw new InvalidOperationException("FINOWL_API_KEY is not set.");
public async Task<long> GetBreachCountAsync(string password, CancellationToken ct = default)
{
for (var attempt = 1; attempt <= 3; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.finowl.co/v1/breaches/password")
{
Content = JsonContent.Create(new { password }),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
using var response = await http.SendAsync(request, ct);
// Exceptions carry the status only, never the password.
switch (response.StatusCode)
{
case HttpStatusCode.OK:
var result = await response.Content.ReadFromJsonAsync<PasswordResult>(ct);
return result?.Count ?? 0;
case HttpStatusCode.BadRequest:
throw new ArgumentException("Password is empty or over 4096 characters.");
case HttpStatusCode.Unauthorized:
throw new InvalidOperationException("Finowl rejected FINOWL_API_KEY.");
case HttpStatusCode.TooManyRequests:
await Task.Delay(response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5), ct);
continue;
default:
throw new HttpRequestException($"Finowl returned {(int)response.StatusCode}.");
}
}
throw new TimeoutException("Still rate limited after 3 attempts.");
}
}
record PasswordResult(bool Breached, long Count);IN PRODUCTION
Production tips
Pass the request's CancellationToken into GetBreachCountAsync so an abandoned registration also cancels the outbound lookup instead of letting it finish in the background.
Never attach the password to structured log scopes or exception data. Serilog and similar libraries capture properties eagerly, so keep the value in a local variable that no logger sees.
Decide your failure policy explicitly. Treating timeouts and 5xx responses as a pass keeps signups working, while rejecting the password instead is stricter but blocks users during an outage.
Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.
A FEW GOOD QUESTIONS
Common questions
Related guides
- Check email breaches in C# with HttpClient
- Predict gender from names in C# with .NET 8
- Check breached passwords in Python at signup
- Screen breached passwords in Node.js
- Check breached passwords in TypeScript
- Check breached passwords in Go before storing them
- Breached password checks for fintech accounts
- Breached password screening for e-commerce accounts
- Breached password checks for SaaS signup and resets
Check breached passwords in C# and .NET 8
Grab an API key, paste the example and run it. 100 free credits every month.