Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix weak password hashing in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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>
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 30 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerable…/Passwords/impl/VulnerablePasswordHasher.csC#
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

Fixedsrc/Passwords/impl/FixedPasswordHasher.csC#
// 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

Fixedsrc/Passwords/impl/FixedPasswordHasher.csC#
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

Fixedsrc/Passwords/Program.csC#
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

Wrong fixsrc/Passwords/impl/WrongPasswordHasher.csC#
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 regression test

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.

Testtests/Passwords.Tests/ExploitTests.csC#
/// <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"));
    }
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix weak password hashing in C# and ASP.NET Core
ToolRuleWhat it found here
CodeQLcs/hash-without-saltExperimental, 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 analysisCA5350, CA5351Do not use weak / broken cryptographic algorithmsNo 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 analysisCA5387, CA5388Do not use weak key derivation function with insufficient iteration countNot 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.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.Sqlite10.0.12 (SQLite 3.53.3 via SQLitePCLRaw 2.1.12)
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform v2)

Run the tests

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.

Shell
dotnet restore tests/Passwords.Tests --locked-mode
Shell
# 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.xml

Each 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.

Download the project ZIP, 30 KB

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

  1. OWASP Password Storage Cheat Sheet
  2. PasswordHasherOptions.IterationCount (ASP.NET Core 10)
  3. PasswordHasher<TUser> source, release/10.0 (V2 and V3 formats, rehash rules)
  4. UserManager<TUser>.CheckPasswordAsync source, release/10.0 (rehash on SuccessRehashNeeded)
  5. CryptographicOperations.FixedTimeEquals
  6. dotnet/runtime#19933: Add Argon2 support to System.Security.Cryptography
  7. OWASP ASVS 5.0.0: Appendix C, password hashing parameters
  8. NIST SP 800-63B-4: Password verifiers
  9. CA5387: Do not use weak key derivation function with insufficient iteration count

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.