Skip to main content

How to Hash Passwords in C#: PBKDF2 & bcrypt Guide

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 compute Hash(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 normal SequenceEqual returns as soon as it finds a mismatched byte, and that timing difference can leak information. CryptographicOperations.FixedTimeEquals always takes the same time.
  • Graceful failure: a corrupted hash returns false instead 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 == or SequenceEqual to compare hashes. Use CryptographicOperations.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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...