
Learn password hashing in C# the right way: salting, PBKDF2, bcrypt, and ASP.NET Core Identity, with runnable .NET code and best practices. Read the guide now.
If your app stores user passwords, how you store them can decide whether a database breach is a small problem or front-page news. Password hashing in C# is one of the most important security skills a .NET developer can have, and it's also one of the most often done wrong. This guide explains what hashing and salting actually do, why plain SHA-256 isn't good enough, and how to use PBKDF2, bcrypt, and ASP.NET Core Identity's PasswordHasher correctly. Every example is runnable code for modern .NET (8, 9, and 10).
Whether you're a beginner looking up "how to hash a password in C#" or a senior engineer checking your team's approach against current OWASP guidance, you'll find practical, production-ready answers here.
Why You Should Never Store Plain-Text or Encrypted Passwords
Start with the threat model. Assume an attacker will eventually get a copy of your users table, whether through SQL injection, a leaked backup, a misconfigured cloud bucket, or an insider. Your goal is to make that stolen data as close to useless as possible.
- Plain text: every account is compromised instantly. Because people reuse passwords, their email, banking, and work accounts are exposed too.
- Encryption (AES, etc.): encryption can be reversed. If the attacker also gets the key, and keys often sit on the same server, every password can be recovered. Your application never needs to know a user's original password. It only needs to check that a login attempt matches.
- Hashing: a one-way function. You store
Hash(password), and at login you computeHash(attempt)and compare the two. Nobody can "decrypt" a hash.
That's why the industry standard is hashing, not encryption. But not every hash function is fit for passwords.
Why SHA-256 and MD5 Are the Wrong Choice for Passwords
A lot of tutorials show something like this:
// ❌ DON'T DO THIS
using System.Security.Cryptography;
using System.Text;
string password = "Summer2026!";
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(password));
Console.WriteLine(Convert.ToHexString(hash));
This code has two serious problems.
Problem 1: It's too fast
SHA-256 was designed to be fast, because it's used for file integrity, TLS, and digital signatures. For passwords, speed helps the attacker. A single modern GPU can compute billions of SHA-256 hashes per second, so an attacker with your hashes can try every common password and dictionary variation in minutes. MD5 and SHA-1 are even faster, and they're also cryptographically broken.
Problem 2: No salt
Without a salt, every user with the password Summer2026! gets the same hash. Attackers use precomputed rainbow tables (huge lookup tables mapping hashes back to passwords), and they can crack every matching account at once.
What Is Password Salting (and Why It Matters)?
A salt is a random value generated separately for each password and mixed in before hashing. It gives you three things:
- The same password produces a different hash for each user.
- Rainbow tables stop working, because the attacker would need a separate table for every salt.
- Attackers have to crack each hash one at a time, not the whole database at once.
A salt is not a secret. You store it right next to the hash. What matters is that it's unique and unpredictable, so always generate it with a cryptographically secure random number generator:
using System.Security.Cryptography;
// ✅ Cryptographically secure 16-byte (128-bit) salt
byte[] salt = RandomNumberGenerator.GetBytes(16);
Console.WriteLine(Convert.ToBase64String(salt));
Pitfall: never use System.Random for salts, tokens, or anything security-related. Its output is predictable.
Key Stretching: What PBKDF2, bcrypt, and Argon2 Actually Do
Salting fixes the rainbow table problem, but fast hashes are still fast. The fix is a password hashing function (also called a key derivation function) that is deliberately slow and has a tunable cost setting:
- PBKDF2: runs HMAC (for example HMAC-SHA256) hundreds of thousands of times. It's built into .NET, FIPS-approved, and widely supported.
- bcrypt: based on the Blowfish cipher, with an exponential "work factor." It has been battle-tested since 1999 and is harder to speed up on GPUs than PBKDF2.
- Argon2id: won the Password Hashing Competition. It's memory-hard, which makes GPU and ASIC attacks expensive. It's OWASP's first recommendation for new systems.
The idea: if checking one password takes your server around 100–300 ms, a user logging in won't notice. An attacker trying billions of guesses, on the other hand, is slowed down by a factor of millions.
Current OWASP recommendations
- Argon2id: at least 19 MiB memory, 2 iterations, 1 degree of parallelism
- bcrypt: work factor of at least 10 (12 is a common modern default)
- PBKDF2-HMAC-SHA256: 600,000 iterations
- PBKDF2-HMAC-SHA512: 210,000 iterations
How to Hash Passwords in C# with PBKDF2 (Built-In, No Packages)
Since .NET 6, the static Rfc2898DeriveBytes.Pbkdf2 method is the simplest and safest way to use PBKDF2. (The older Rfc2898DeriveBytes instance constructors default to weak settings such as SHA-1 and 1,000 iterations, and recent .NET versions mark them obsolete. Avoid them.)
Here is a complete, production-style password hasher for C#:
using System.Security.Cryptography;
public static class Pbkdf2PasswordHasher
{
private const int SaltSize = 16; // 128-bit salt
private const int HashSize = 32; // 256-bit hash
private const int Iterations = 600_000; // OWASP recommendation for SHA-256
private const string AlgorithmId = "PBKDF2-SHA256";
private static readonly HashAlgorithmName Algorithm = HashAlgorithmName.SHA256;
// Format: PBKDF2-SHA256$iterations$saltBase64$hashBase64
public static string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algorithm, HashSize);
return string.Join('$',
AlgorithmId,
Iterations,
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public static bool Verify(string password, string storedHash)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(storedHash))
return false;
string[] parts = storedHash.Split('$');
if (parts.Length != 4 || parts[0] != AlgorithmId)
return false;
if (!int.TryParse(parts[1], out int iterations) || iterations <= 0)
return false;
byte[] salt;
byte[] expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch (FormatException)
{
return false;
}
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algorithm, expected.Length);
// Constant-time comparison prevents timing attacks
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
// True if the hash was created with older (weaker) settings
public static bool NeedsRehash(string storedHash)
{
string[] parts = storedHash.Split('$');
return parts.Length != 4
|| parts[0] != AlgorithmId
|| !int.TryParse(parts[1], out int iterations)
|| iterations < Iterations;
}
}
And here it is in use:
string stored = Pbkdf2PasswordHasher.Hash("Summer2026!");
Console.WriteLine(stored);
// PBKDF2-SHA256$600000$3q2+7w...==$Yk9x...=
Console.WriteLine(Pbkdf2PasswordHasher.Verify("Summer2026!", stored)); // True
Console.WriteLine(Pbkdf2PasswordHasher.Verify("summer2026!", stored)); // False
Why this design works
- Self-describing format: the algorithm, iteration count, and salt are stored inside the hash string. You can raise the iteration count later without breaking existing users.
FixedTimeEquals: a normalSequenceEqualreturns as soon as it finds a mismatched byte, and that timing difference can leak information.CryptographicOperations.FixedTimeEqualsalways takes the same time.- Graceful failure: a corrupted hash returns
falseinstead of throwing an exception with a stack trace.
Using ASP.NET Core Identity's PasswordHasher
If you're building an ASP.NET Core app, you often don't need to write any of this. ASP.NET Core Identity's PasswordHasher<TUser> already uses PBKDF2 (HMAC-SHA512 with 100,000 iterations by default since .NET 7), a random salt, a versioned format, and constant-time comparison. You can use it on its own, without the rest of Identity, by installing Microsoft.Extensions.Identity.Core:
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
public class AppUser
{
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
var options = Options.Create(new PasswordHasherOptions
{
// Raise toward OWASP's 210,000 for PBKDF2-HMAC-SHA512
IterationCount = 210_000
});
var hasher = new PasswordHasher<AppUser>(options);
var user = new AppUser { Email = "jane@example.com" };
user.PasswordHash = hasher.HashPassword(user, "Summer2026!");
PasswordVerificationResult result =
hasher.VerifyHashedPassword(user, user.PasswordHash, "Summer2026!");
switch (result)
{
case PasswordVerificationResult.Success:
Console.WriteLine("Login OK");
break;
case PasswordVerificationResult.SuccessRehashNeeded:
// Hash uses old settings - upgrade it while we have the plain password
user.PasswordHash = hasher.HashPassword(user, "Summer2026!");
Console.WriteLine("Login OK - hash upgraded");
break;
default:
Console.WriteLine("Invalid credentials");
break;
}
The SuccessRehashNeeded result is useful. It lets you migrate users to stronger settings silently the next time they log in.
How to Use bcrypt in C# with BCrypt.Net-Next
.NET has no built-in bcrypt, but BCrypt.Net-Next is the widely used, well-maintained NuGet package:
// dotnet add package BCrypt.Net-Next
const int WorkFactor = 12;
string hash = BCrypt.Net.BCrypt.HashPassword("Summer2026!", workFactor: WorkFactor);
Console.WriteLine(hash);
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
bool valid = BCrypt.Net.BCrypt.Verify("Summer2026!", hash);
Console.WriteLine(valid); // True
// Check whether a stored hash should be upgraded
bool upgrade = BCrypt.Net.BCrypt.PasswordNeedsRehash(hash, WorkFactor);
bcrypt creates and embeds the salt for you. The output string contains the version ($2a$), the cost (12), a 22-character salt, and the hash itself. Every time you add 1 to the work factor, hashing takes twice as long.
The bcrypt 72-byte limit
bcrypt ignores everything after the first 72 bytes of the password. Long passphrases, and emoji or non-Latin text that takes several bytes per character in UTF-8, can be cut off silently. If you need to support very long passwords, BCrypt.Net-Next provides EnhancedHashPassword and EnhancedVerify, which pre-hash the input with SHA-384 before running bcrypt:
string enhanced = BCrypt.Net.BCrypt.EnhancedHashPassword(longPassphrase, workFactor: 12);
bool ok = BCrypt.Net.BCrypt.EnhancedVerify(longPassphrase, enhanced);
You have to verify with the method that matches the one you hashed with. Don't mix the standard and enhanced calls.
PBKDF2 vs bcrypt vs Argon2: Which Should You Use in .NET?
- Use ASP.NET Core Identity's PasswordHasher if you're on ASP.NET Core. It's maintained by Microsoft, needs zero crypto code from you, and supports rehashing.
- Use PBKDF2 (
Rfc2898DeriveBytes.Pbkdf2) if you need FIPS-140 compliance (common in US government, healthcare, and finance) or can't add dependencies. - Use bcrypt if you're sharing a user database with Node.js, PHP, Python, or Ruby apps. Almost every platform understands
$2a$/$2b$hashes. - Use Argon2id for new, high-security systems where you control the whole stack. In .NET this needs a third-party package such as
Konscious.Security.Cryptography.Argon2. Check how actively it's maintained and benchmark memory use under load before you commit.
All four are acceptable when configured correctly. The real risk is choosing none of them and using SHA-256 or MD5 instead.
Password Security Best Practices for .NET Developers
1. Tune the cost to your hardware
Benchmark on production-class hardware and aim for roughly 100–300 ms per hash. A quick way to measure:
using System.Diagnostics;
var sw = Stopwatch.StartNew();
Pbkdf2PasswordHasher.Hash("benchmark-password");
sw.Stop();
Console.WriteLine($"PBKDF2 hash took {sw.ElapsedMilliseconds} ms");
sw.Restart();
BCrypt.Net.BCrypt.HashPassword("benchmark-password", workFactor: 12);
sw.Stop();
Console.WriteLine($"bcrypt hash took {sw.ElapsedMilliseconds} ms");
2. Protect the login endpoint
Slow hashing uses CPU on purpose, so an unprotected login endpoint becomes an easy denial-of-service target. Use ASP.NET Core's built-in rate limiting middleware (AddRateLimiter), account lockout, and CAPTCHA after repeated failures.
3. Consider a pepper
A pepper is a secret key kept outside the database, for example in Azure Key Vault or AWS Secrets Manager. You apply it with HMAC before hashing. If only the database leaks, the hashes can't be cracked without the pepper. This is defense in depth, not a replacement for proper hashing.
4. Upgrade hashes on login
Store the algorithm and cost in the hash string, as all the examples above do. When a user logs in successfully, check NeedsRehash and re-hash with current settings. For legacy MD5 or SHA-1 hashes, you can wrap them right away: store bcrypt(legacyHash), then fully migrate each user at their next login.
5. Validate passwords sensibly
Follow NIST SP 800-63B: require at least 8 characters (12–15 or more is better), allow at least 64, check new passwords against known-breached lists (such as the Have I Been Pwned k-anonymity API), and drop arbitrary composition rules and forced periodic resets.
Common Password Hashing Pitfalls to Avoid
- Reusing one global salt for every user. Each password needs its own random salt.
- Using
==orSequenceEqualto compare hashes. UseCryptographicOperations.FixedTimeEquals. - Using the old
new Rfc2898DeriveBytes(password, salt)constructor, which defaults to SHA-1 and 1,000 iterations. - Hard-coding a low iteration count and never revisiting it. Recommended costs go up as hardware gets faster.
- Logging passwords through request logging, exception messages, or telemetry. Scrub sensitive fields.
- Writing your own algorithm, such as
SHA256(SHA256(password + "secret")). Use proven, peer-reviewed functions. - Returning different errors for "user not found" and "wrong password." That lets attackers find out which usernames exist.
Conclusion: Password Hashing in C# Done Right
Password hashing in C# isn't difficult once you know the rules. The .NET platform gives you solid tools, and the mistakes almost always come from outdated tutorials. Key takeaways:
- Hash, don't encrypt. Passwords should never be recoverable.
- Never use MD5, SHA-1, or plain SHA-256 for passwords. They're too fast.
- Always use a unique, random salt from
RandomNumberGenerator. - Use a slow, tunable algorithm: PBKDF2 (600,000 iterations with SHA-256), bcrypt (work factor 12), or Argon2id.
- On ASP.NET Core, use Identity's
PasswordHasher<TUser>. It's the easiest secure option. - Compare hashes in constant time, store cost settings with the hash, and rehash on login.
- Add layers: rate limiting, breached-password checks, and optionally a pepper kept in a secrets vault.
Check your current codebase today. If you find SHA256.HashData(password) or MD5.Create() anywhere near a login form, you now know how to fix it, and how to migrate existing users without forcing a password reset.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment