How to hash password in .NET

Introduction
If your database gets breached and passwords come out in plain text, that's not a bug in your hashing - it's the absence of any. This video builds password hashing from scratch in .NET: not a custom home-rolled algorithm (never do that), but the standard building blocks .NET already ships - PBKDF2 through Rfc2898DeriveBytes, a random salt per user, and enough iterations to make brute-forcing expensive.
🎬 Watch the full video here:
Why plain hashing isn't enough
A raw SHA256 hash of a password is fast to compute - which is exactly the problem. Fast means an attacker with a stolen hash database can try billions of guesses per second on commodity GPUs, and a rainbow table (precomputed hashes of common passwords) makes cracking a naive SHA256("password123") instant, since the same input always produces the same output. Password hashing needs to be deliberately slow and needs to break the "same input, same output" property that lets attackers precompute anything.
Salting breaks the precomputation
A salt is a random value generated per user and mixed into the hash:
byte[] salt = RandomNumberGenerator.GetBytes(16);
Two users with the identical password "Summer2024!" now produce two completely different hashes, because each hash is computed from password + salt. This kills rainbow tables outright - precomputing a table would require one per possible salt, which defeats the purpose of precomputing anything.
PBKDF2: hashing that's supposed to be slow
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password: Encoding.UTF8.GetBytes(password),
salt: salt,
iterations: 100_000,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: 32);
The iterations parameter is the whole point: PBKDF2 runs the underlying hash function that many times in sequence, so computing one hash takes a deliberate, tunable amount of work. 100,000 iterations is imperceptible for a single login attempt (a few milliseconds) but makes brute-forcing millions of guesses meaningfully expensive. As hardware gets faster, the fix is raising the iteration count, not switching algorithms.
What you actually store
Never store the plain password - store the salt and the resulting hash, concatenated or in separate columns:
string stored = $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
Verifying a login means repeating the exact same computation with the stored salt and comparing the result:
bool isValid = CryptographicOperations.FixedTimeEquals(hash, computedHash);
FixedTimeEquals matters more than it looks - a naive == on byte arrays returns as soon as it finds a mismatched byte, and that timing difference is (in principle) measurable and exploitable as a timing side-channel. A fixed-time comparison always takes the same time regardless of where the mismatch is.
Why not just use ASP.NET Core Identity's hasher
If you're already on ASP.NET Core Identity, PasswordHasher<TUser> does exactly this - PBKDF2, per-user salt, versioned output format that can be upgraded later - and you should use it rather than reimplementing it. This video builds it manually specifically to show what Identity is doing under the hood, which matters the moment you're not using Identity (a legacy system, a non-Identity auth scheme) and have to roll this yourself.
Common pitfalls
- Using
MD5orSHA256alone. Fast hash functions are the wrong tool for passwords - they're built for speed, which is the opposite of what you want here. - Reusing one global salt for every user. Defeats the purpose; each user needs their own random salt.
- A fixed, too-low iteration count that never gets revisited. Hardware gets faster; the cost of your hash should be revisited over time.
- Comparing hashes with a plain
==orSequenceEqual. Use a fixed-time comparison to avoid timing attacks. - Rolling your own KDF instead of using PBKDF2/Argon2/bcrypt. Don't invent cryptography; use vetted algorithms.
Key Takeaways
- Never store passwords in plain text or hash them with a fast general-purpose hash like SHA256.
- A random per-user salt defeats rainbow tables by making identical passwords hash differently.
- PBKDF2's iteration count is a deliberate slowdown - tune it up as hardware gets faster.
- Store the salt alongside the hash; verification recomputes the hash and compares.
- Use
CryptographicOperations.FixedTimeEqualsfor the comparison to avoid timing side-channels. - If you're on ASP.NET Core Identity, use its built-in
PasswordHasher<TUser>rather than reimplementing this.
Get the Full Source Code
The complete runnable solution - the hashing and verification helpers, and a minimal registration/login endpoint using them - is available to Patreon supporters. If you want to drop working password hashing into a non-Identity project instead of rebuilding it from the walkthrough above, you can find it on Patreon.