Use ASP.NET Core Identity's PasswordHasher<TUser> (PBKDF2-HMAC-SHA512, 128-bit salt) with PasswordHasherOptions.IterationCount = 220_000; .NET 10's default of 100,000 is below OWASP's figure. Wrap it in an IPasswordHasher<TUser> that checks legacy SHA-256 rows with CryptographicOperations.FixedTimeEquals and returns SuccessRehashNeeded, then rehash whenever verification returns SuccessRehashNeeded.
- Find every place that hashes or compares passwords: SHA256.HashData or ComputeHash on a password, Rfc2898DeriveBytes with a low iteration count, a PasswordHasher in IdentityV2 compatibility mode, and == or SequenceEqual between a computed and a stored hash.
- Hash new passwords with PasswordHasher<TUser> and set PasswordHasherOptions.IterationCount to at least 220,000 (services.Configure<PasswordHasherOptions>(o => o.IterationCount = 220_000) when Identity creates the hasher). It ships in the ASP.NET Core shared framework; no package is needed.
- Wrap it in your own IPasswordHasher<TUser>: recognise the old format first (here, 64 lowercase hex digits), compare its digest with CryptographicOperations.FixedTimeEquals and return SuccessRehashNeeded on a match; pass everything else to the wrapped PasswordHasher.
- Treat SuccessRehashNeeded as success, then hash the password again and save it with UPDATE ... WHERE id = $id AND password_hash = <the hash you verified>. With Identity's UserManager, CheckPasswordAsync() does this rehash for you.
- When the user name is unknown, verify against a dummy hash anyway and return the same 401, so neither the body nor the timing shows which accounts exist.
- Count the legacy rows that remain and decide what happens to them: expire them after a deadline and require a reset, or wrap them now as PBKDF2 over SHA-256(password) behind a marker until their owners log in.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public string HashPassword(AppUser user, string password) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(password)));With IMPL=vulnerable, 4 of 11 tests fail (4 exploit tests).
The fix: hashing policy
// OWASP's minimum for PBKDF2-HMAC-SHA512 is 220,000 iterations. .NET 10's default is
// 100,000, so set it explicitly. Identity verifies older hashes at the count stored in
// each one and reports SuccessRehashNeeded when that count is below this setting.
public const int IterationCount = 220_000;
private static readonly PasswordHasher<AppUser> Identity =
new(Options.Create(new PasswordHasherOptions { IterationCount = IterationCount }));
public string HashPassword(AppUser user, string password) => Identity.HashPassword(user, password);With IMPL=fixed, 11 of 11 tests pass.
The fix: verify and upgrade
public PasswordVerificationResult VerifyHashedPassword(AppUser user, string hashedPassword, string providedPassword)
{
if (hashedPassword.Length == 64 && hashedPassword.All(char.IsAsciiHexDigitLower))
{
// A row from the old system: unsalted SHA-256. Compare in constant time, and ask
// for a rehash so the next successful login replaces it.
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(providedPassword));
return CryptographicOperations.FixedTimeEquals(digest, Convert.FromHexString(hashedPassword))
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Failed;
}
// Identity compares with CryptographicOperations.FixedTimeEquals as well.
return Identity.VerifyHashedPassword(user, hashedPassword, providedPassword);
}With IMPL=fixed, 11 of 11 tests pass.
The fix: write the upgrade at login
app.MapPost("/login", (Credentials credentials, UsersDb db, IPasswordHasher<AppUser> hasher) =>
{
if (!credentials.IsValid(out var userName, out var password))
{
return badRequest;
}
var user = db.FindByUserName(userName);
var checkedAgainst = user ?? dummy;
var result = hasher.VerifyHashedPassword(checkedAgainst.User, checkedAgainst.PasswordHash, password);
if (user is null || result == PasswordVerificationResult.Failed)
{
return Results.Json(new { error = "invalid username or password" }, statusCode: StatusCodes.Status401Unauthorized);
}
if (result == PasswordVerificationResult.SuccessRehashNeeded)
{
// Only now is the plaintext known to be right. Replace exactly the hash that was
// verified: if a password change landed in the meantime, no row matches.
db.ReplaceHash(user.User.Id, user.PasswordHash, hasher.HashPassword(user.User, password));
}
return Results.Ok(new { username = userName });
});With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
public string HashPassword(AppUser user, string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
return $"sha256${Convert.ToHexStringLower(salt)}${Convert.ToHexStringLower(Sha256(salt, password))}";
}
private static byte[] Sha256(byte[] salt, string password)
{
using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
sha256.AppendData(salt);
sha256.AppendData(Encoding.UTF8.GetBytes(password));
return sha256.GetHashAndReset();
}With IMPL=wrong, 3 of 11 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: what a stolen copy of the users table gives an attacker, and how hashes upgrade.</summary>
public sealed class ExploitTests(PasswordsApiFactory factory) : PasswordsApiTests(factory)
{
[Fact]
public async Task ANewHashIsPbkdf2Sha512AtOrAboveTheOwaspMinimum()
{
Assert.Equal(HttpStatusCode.Created, await Register("ivan", "ivan-has-a-long-passphrase"));
AssertMeetsPolicy(StoredHash("ivan"));
}
[Fact]
public async Task TheSamePasswordGivesEachUserADifferentHash()
{
await Register("judy", LegacyPassword);
await Register("mallory", LegacyPassword);
Assert.NotEqual(StoredHash("judy"), StoredHash("mallory"));
}
[Fact]
public async Task ALegacySha256HashIsReplacedAfterASuccessfulLogin()
{
var legacy = StoredHash("alice");
Assert.Equal(HttpStatusCode.OK, await Login("alice", LegacyPassword));
Assert.NotEqual(legacy, StoredHash("alice"));
AssertMeetsPolicy(StoredHash("alice"));
Assert.Equal(HttpStatusCode.OK, await Login("alice", LegacyPassword)); // the new hash verifies
}
[Fact]
public async Task AWrongPasswordIsRejectedAndLeavesTheLegacyHashAlone()
{
var legacy = StoredHash("alice");
Assert.Equal(HttpStatusCode.Unauthorized, await Login("alice", "correct horse battery stapler"));
Assert.Equal(legacy, StoredHash("alice"));
}
[Fact]
public async Task AHashMadeWithIdentitysDefaultIterationCountIsRehashedAfterLogin()
{
// PasswordHasher<TUser> with default options: 100,000 iterations of HMAC-SHA512 in .NET 10.
var weak = new PasswordHasher<AppUser>().HashPassword(new AppUser(2, "bob"), LegacyPassword);
Assert.Equal(100_000u, ParseIdentityV3(weak)!.Value.Iterations);
SetStoredHash("bob", weak);
Assert.Equal(HttpStatusCode.OK, await Login("bob", LegacyPassword));
AssertMeetsPolicy(StoredHash("bob"));
}
}- PasswordHasher<TUser> is PBKDF2, not Argon2id, and .NET 10 has no Argon2 of its own (dotnet/runtime#19933, an API suggestion, is still open). OWASP accepts PBKDF2 and prefers it where FIPS-140 validation is required; its figure for HMAC-SHA512 is 220,000 iterations, while ASVS 5.0.0's Appendix C, written earlier, says 210,000.
- In .NET 10 the V3 format is PBKDF2 with HMAC-SHA512, a 128-bit salt, a 256-bit subkey and 100,000 iterations by default (the documented default of PasswordHasherOptions.IterationCount). A test in this card checks that default on the installed runtime.
- IterationCount only affects new hashes. VerifyHashedPassword() reads the count stored in each hash and returns SuccessRehashNeeded when it is lower than the setting, when the PRF is HMAC-SHA1 or HMAC-SHA256, or when the hash is in the V2 format (PBKDF2-HMAC-SHA1, 1,000 iterations).
- VerifyHashedPassword() starts with Convert.FromBase64String, so a stored value that is not Base64 (for example sha256$salt$hash) throws FormatException instead of failing; 64 hex digits happen to be valid Base64 and just fail. Route legacy formats before calling it.
- PBKDF2's cost is CPU time: 220,000 iterations of HMAC-SHA512 took about 75 ms per hash here, on the request thread. Bound concurrent logins, and note that VerifyHashedPassword() also uses the iteration count stored in the hash, so bound it if anything other than this code can write the users table.
- CryptographicOperations.FixedTimeEquals() returns false immediately when the lengths differ, which is fine for fixed-length digests; decode the stored hex to bytes first rather than comparing strings.
- NIST SP 800-63B-4 recommends NFC normalization before hashing, while ASVS 5.0 (6.2.8) asks for the password to be verified exactly as received. This card hashes the UTF-8 bytes it receives; if you normalize, do it identically on every path, and remember that existing hashes were made without it.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/csharp-aspnetcore does this for you).
- Look at real rows: Base64-decode a stored hash; byte 0 is 0x01 for V3 and the four bytes from offset 5 hold the iteration count, big-endian. Accounts created or used since the change should show 220,000 or more, and the number of rows still matching ^[0-9a-f]{64}$ should fall as people log in.
- The .NET analyzers flag MD5 and SHA-1 but not a fast SHA-256 password hash, and the CodeQL check for unsalted hashes is experimental, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| CodeQL | cs/hash-without-salt | Experimental, so not in the default suites; read from the source, not run. Sources are variables whose whole name is password, passwd, passcode or passphrase (any case), sinks are HashAlgorithm.ComputeHash or HashData calls, so it matches VulnerablePasswordHasher.HashPassword. The legacy check in FixedPasswordHasher uses a parameter named providedPassword, which the pattern does not match, and no C# query checks PBKDF2 iteration counts.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis | CA5350, CA5351Do not use weak / broken cryptographic algorithms | No warnings in src/: SHA-256 is not on either rule's list. A probe project got CA5351 for MD5.HashData and CA5350 for SHA1.HashData, so the analyzers were active.Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All |
| .NET code analysis | CA5387, CA5388Do not use weak key derivation function with insufficient iteration count | Not applicable to this card, which never calls Rfc2898DeriveBytes. The rules look at Rfc2898DeriveBytes instances (GetBytes with fewer than 100,000 iterations; off by default in .NET 10): a probe calling the static Rfc2898DeriveBytes.Pbkdf2 with 1,000 HMAC-SHA1 iterations got no warning.Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API with ASP.NET Core Identity's PasswordHasher<TUser> |
| Microsoft.Extensions.Identity.Core: PasswordHasher<TUser> (ASP.NET Core shared framework) | 10.0.12 |
| Microsoft.Data.Sqlite | 10.0.12 (SQLite 3.53.3 via SQLitePCLRaw 2.1.12) |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/csharp-aspnetcorefolder.
dotnet restore tests/Passwords.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Passwords.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Passwords.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Passwords.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs password-hashing/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- Apple M5 Max, macOS 27.0
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- aspnetcoreRuntime 10.0.12
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 4 of 11 tests fail (4 exploit tests); wrong: 3 of 11 tests fail (3 exploit tests)
- Test results
- Offline run of run-all.mjs (outbound network blocked): fixed 11/11 pass; vulnerable 4/11 fail (4 exploit); wrong 3/11 fail (3 exploit)
- Hash cost
- 74.8 ms per hash at 220,000 iterations and 34.1 ms at the default 100,000 (PasswordHasher V3, mean of 10 after warm-up)
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Password Storage Cheat Sheet
- PasswordHasherOptions.IterationCount (ASP.NET Core 10)
- PasswordHasher<TUser> source, release/10.0 (V2 and V3 formats, rehash rules)
- UserManager<TUser>.CheckPasswordAsync source, release/10.0 (rehash on SuccessRehashNeeded)
- CryptographicOperations.FixedTimeEquals
- dotnet/runtime#19933: Add Argon2 support to System.Security.Cryptography
- OWASP ASVS 5.0.0: Appendix C, password hashing parameters
- NIST SP 800-63B-4: Password verifiers
- CA5387: Do not use weak key derivation function with insufficient iteration count
Attack patterns: