Hashing shows up in a lot of places in a typical .NET application. You use it to validate a JWT signature, to check file integrity, to compare API payloads, and, most sensitively, to store passwords. The mistake I see repeatedly on client projects is developers treating all of these use cases the same way and reaching for whichever hash method they used last time. That is a bad habit, because the right hash algorithm for password storage is deliberately slow, while the right hash algorithm for JWT validation or integrity checks needs to be fast. Mixing these up either creates a performance problem or a security problem.
This article walks through three ways to create hashes in .NET: SHA512 used directly, Rfc2898DeriveBytes.Pbkdf2 with a salt, and the PasswordHasher class from ASP.NET Core Identity. Each one has a clear use case, and I will point out where each one falls short so you do not end up using it in the wrong place.
Where hashing fits and where it should not
Before looking at code, it is worth being clear about scope. A hash is a one way function: you can turn data into a hash value, but you cannot reverse a hash value back into the original data. That property makes hashing useful for verifying that a piece of data has not changed, or for verifying that a submitted value matches a stored value, without ever storing the original value in plain text.
As a general rule, avoid storing hash values in your own database if you can help it. If you can delegate authentication to an identity provider such as Microsoft Entra ID, Keycloak, or Auth0, do that instead and you sidestep the whole problem of storing credential material. When you genuinely need to hash and store something yourself, use the recommended approach for that specific scenario rather than a generic one size fits all hash function.
Using SHA512 directly
SHA512 is the fastest and simplest option in .NET. It takes a string, hashes it, and returns a fixed size value. This is appropriate when you are not persisting the hash in a database, or when there is no realistic window for an attacker to run a dictionary or brute force attack against the value, for example a short lived one time code that expires in a few minutes.
public static string ToHashedCodeV1(string code)
{
using var sha512 = SHA512.Create();
var bytes = Encoding.UTF8.GetBytes(code);
var hash = sha512.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
public static bool VerifyCodeV1(string code, string storedCode)
{
using var sha512 = SHA512.Create();
var bytes = Encoding.UTF8.GetBytes(code);
var hash = sha512.ComputeHash(bytes);
var storedHash = Convert.FromBase64String(storedCode);
return CryptographicOperations.FixedTimeEquals(hash, storedHash);
}
ToHashedCodeV1 converts the input string to UTF8 bytes, runs it through SHA512, and base64 encodes the result for storage or transport. VerifyCodeV1 recomputes the hash from the incoming code and compares it against the stored hash. Notice that the comparison uses CryptographicOperations.FixedTimeEquals rather than a plain equality check or string comparison. A normal comparison returns as soon as it finds the first mismatched byte, which leaks timing information an attacker can use to guess the correct value one byte at a time. FixedTimeEquals always takes the same amount of time regardless of where the mismatch occurs, closing that side channel.
A common mistake here is disposing the SHA512 instance incorrectly or forgetting the using statement, which leaks unmanaged resources over time in a busy application. Another one is comparing hashes with string equality instead of a fixed time comparison, which technically works but quietly introduces a timing attack vector that is easy to miss in code review.
Newer versions of .NET also expose a static HashDataAsync method that avoids allocating and disposing the SHA512 instance yourself.
public static async Task<string> ToHashedCodeV2(string code)
{
var bytes = Encoding.ASCII.GetBytes(code);
var hash = await SHA512.HashDataAsync(new MemoryStream(bytes));
return Convert.ToHexString(hash);
}
public static async Task<bool> VerifyCodeV2(string code, string storedCode)
{
var storedHash = Convert.FromHexString(storedCode);
var bytes = Encoding.ASCII.GetBytes(code);
var hash = await SHA512.HashDataAsync(new MemoryStream(bytes));
return CryptographicOperations.FixedTimeEquals(hash, storedHash);
}
This version wraps the bytes in a MemoryStream and hashes them asynchronously, which is convenient when the input is already coming from a stream, for example a file upload. It also switches to hex encoding through Convert.ToHexString and Convert.FromHexString instead of base64, which is purely a formatting choice and does not change the security properties. Either encoding is fine as long as you are consistent between the hashing and verification code paths.
Whichever variant you pick, remember that plain SHA512 is intentionally fast, which is exactly what makes it unsuitable for password storage. A modern GPU can compute billions of SHA512 hashes per second, so if a database of SHA512 password hashes leaks, an attacker can run a dictionary attack against it very quickly.
Using Rfc2898DeriveBytes.Pbkdf2
For anything where the hash will sit in a database for a longer time, you want a slow, salted algorithm instead. Rfc2898DeriveBytes.Pbkdf2 is a key derivation function built for exactly this. It combines a salt with the input value and repeats the underlying hash operation many times, which makes brute force attacks computationally expensive. Microsoft’s own guidance is to use a salt of at least 8 bytes and at least 10,000 iterations, and in practice I would push the iteration count higher on modern hardware unless you have a measured latency budget that stops you.
private const int _keySize = 32;
private const int _iterations = 10000;
private static readonly HashAlgorithmName _algorithm = HashAlgorithmName.SHA512;
public static string ToHashedCode(string toHash, string userId)
{
var salt = Encoding.UTF8.GetBytes(userId);
var hash = Rfc2898DeriveBytes.Pbkdf2(
toHash,
salt,
_iterations,
_algorithm,
_keySize
);
return Convert.ToBase64String(hash);
}
public static bool VerifyCode(string code, string userId, string storedCode)
{
var salt = Encoding.UTF8.GetBytes(userId);
var storedHash = Convert.FromBase64String(storedCode);
var hash = Rfc2898DeriveBytes.Pbkdf2(
code,
salt,
_iterations,
_algorithm,
_keySize
);
return CryptographicOperations.FixedTimeEquals(hash, storedHash);
}
The Pbkdf2 call takes the value to hash, a salt, an iteration count, a hash algorithm, and the desired output key size in bytes, and returns a derived key rather than a raw hash. Verification recomputes the derived key using the same salt and iteration count and compares it in fixed time, same as before.
There is a real weakness in this particular example that is worth calling out rather than glossing over: the salt is derived from the userId. A salt should be random and unique per record, generated with a cryptographically secure random number generator and stored alongside the hash, not derived from a predictable value like a username or user ID. If two users happen to have related or guessable identifiers, or if the userId format is predictable, an attacker’s precomputed tables become far more effective than they should be. Generate the salt with RandomNumberGenerator.GetBytes and store it next to the hash in the same row so verification can retrieve it later.
Pbkdf2 is a solid choice when you have a specific reason to roll your own hashing, but it is not the strongest option available today. Argon2id and bcrypt are generally preferred for new password storage designs because they are also memory hard, meaning they resist GPU and ASIC based cracking attempts better than a purely CPU bound iteration count. .NET does not ship Argon2id in the base class library, so you would need a third party package such as Konscious.Security.Cryptography if you want it, and that is worth evaluating before committing to Pbkdf2 for a new system.
Using ASP.NET Core Identity
If your application already uses ASP.NET Core Identity, you get a production tested password hasher for free through the PasswordHasher class. It accepts a generic type parameter representing your user type, and internally it uses Pbkdf2 with a randomly generated salt and a sensible default iteration count, along with version markers so the algorithm can be upgraded later without breaking old hashes.
private readonly PasswordHasher<string> _passwordHasher = new();
public static string ToHashedCode(string code, string userId,
PasswordHasher<string> passwordHasher)
{
var hash = passwordHasher.HashPassword(userId, code);
return hash;
}
public static bool VerifyCode(string code, string userId, string storedCode)
{
var passwordHasher = new PasswordHasher<string>();
var result = passwordHasher.VerifyHashedPassword(userId, storedCode, code);
return result == PasswordVerificationResult.Success;
}
HashPassword takes the user object (here just a string standing in for the user type) and the plain text value, and returns a single string that already contains the salt, the iteration count, and the derived hash packed together. VerifyHashedPassword unpacks all of that automatically and returns a PasswordVerificationResult, which is worth checking closely because it is not just a boolean. It can also return SuccessRehashNeeded, which tells you the stored hash was created with an older, weaker set of parameters and should be rehashed with the current settings the next time the user logs in successfully.
This is the option I would default to whenever the surrounding application is already built on ASP.NET Core Identity, because it removes decisions about salt generation, iteration counts, and encoding formats from your code entirely. The trade off is that it is deliberately slow by design, which is fine for a login flow that runs once per session but the wrong choice for something like hashing millions of records in a batch job.
Picking the right one
In practice the decision comes down to how long the hash lives and what it protects. Use plain SHA512 for short lived, low value data such as a one time code that expires in minutes, where speed matters more than resistance to offline attacks. Use Pbkdf2 directly only when you have a specific reason to manage the parameters yourself, and make sure the salt is genuinely random rather than derived from a predictable field. Use ASP.NET Core Identity’s PasswordHasher whenever you are already inside that framework and need to store credentials, since it encodes current best practice and gives you a rehashing signal for free.
One thing that applies across all three approaches: avoid storing hashed values in your own database whenever you can push authentication to a dedicated identity provider instead. Fewer credential stores means fewer places where a mistake in salt generation, iteration count, or comparison logic can turn into a breach.
Leave a Reply