Web Security & Ethical Hacking · Lesson 8 of 23
Password Hashing — bcrypt, Argon2, and Why It Matters
Why You Can Never Store Plaintext Passwords
When a user sets a password, the intuitive thing is to store it and compare it on login. Never do this.
The moment your database is breached — and breaches happen to companies of every size — every user's password is exposed in plaintext. Because most people reuse passwords across sites, this single breach unlocks their email, banking, and other accounts. You have handed attackers master keys to your users' digital lives.
The solution: store a fingerprint of the password, not the password itself.
What a Hash Function Is
A hash function takes any input and produces a fixed-size output (the hash or digest). It is one-way: you can compute the hash from the input, but you cannot reverse-engineer the input from the hash.
SHA256("password123") → "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
SHA256("password124") → "c3f26c71c2f0e64d55ef2d58ccec50f0a9adf1ee2c5c17b4e7f3bbd5f2c8f74a"Change one character, get a completely different hash. You cannot go backwards.
At login, you hash what the user typed and compare it to the stored hash. If they match, the password is correct. You never store or compare the plaintext.
Why MD5 and SHA1 Are Wrong for Passwords
MD5 and SHA1 are general-purpose hash functions designed to be fast. This is exactly the problem for passwords.
On a modern GPU, you can compute:
- ~10 billion MD5 hashes per second
- ~3 billion SHA256 hashes per second
An attacker who steals your database can try every word in the dictionary, every common password, and billions of variants in minutes.
Rainbow Tables
A rainbow table is a precomputed lookup table: hash → password. Attackers precompute hashes for millions of common passwords. Given a hash, they just look it up. With MD5, cracking "password123" takes milliseconds.
Rainbow tables are why fast hashes are catastrophic for passwords. The attacker does the work once and uses the table forever.
Salting — Defeating Rainbow Tables
A salt is a random value added to the password before hashing, stored alongside the hash:
salt = random_bytes(16) → "a3f8c2..."
hash = SHA256(salt + "password123") → unique hash
stored = { salt: "a3f8c2...", hash: "b7d9f1..." }Each user gets a unique salt, so:
- The same password produces different hashes for different users (rainbow tables useless)
- Attackers must crack each hash individually, not batch-crack all of them at once
Salting defeats precomputed attacks. But it does not solve the speed problem. Attackers just run a fast hash function per user instead of using a table.
To defeat brute force, you need a hash that is slow by design.
bcrypt — Slow by Design
bcrypt was designed in 1999 specifically for password hashing. It has two key properties:
Cost Factor (Work Factor)
bcrypt takes a cost factor (also called rounds) as a parameter, typically 10–14. Each increment doubles the computation time:
cost=10: ~100ms per hash
cost=12: ~400ms per hash
cost=14: ~1600ms per hashAn attacker hashing billions of candidates per second is reduced to a few thousand per second. The same attack that cracks MD5 in minutes takes years against bcrypt.
As hardware gets faster, you increase the cost factor and re-hash passwords on next login. bcrypt ages gracefully.
Built-In Salt
bcrypt generates and stores the salt automatically. The output string contains everything needed for verification:
$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
^ ^ ^--------------salt (22 chars)----------^^-------hash-------^
| |
| cost factor (12)
algorithm version (2a)You never manage salts manually when using bcrypt. They are built into the output.
Argon2 — The Current Gold Standard
Argon2 won the Password Hashing Competition in 2015 and is the current recommended algorithm. It improves on bcrypt in one critical way: it is memory-hard.
Modern GPU attacks against bcrypt are efficient because GPUs have millions of tiny cores that can each run bcrypt independently. Argon2 requires a configurable amount of RAM per hash — GPUs do not have enough RAM per core to run many in parallel.
Three variants:
- Argon2d: Maximizes GPU resistance. Vulnerable to side-channel attacks. Not for passwords.
- Argon2i: Side-channel resistant. Use for password hashing in most cases.
- Argon2id: Hybrid. Recommended for password hashing. Best of both.
Parameters for Argon2id:
- Memory (m): RAM per hash. 64 MB is a reasonable starting point.
- Iterations (t): Number of passes over memory. Start at 1-3.
- Parallelism (p): Number of threads. Match your server's cores.
PBKDF2 — The Standard You Will Find Everywhere
PBKDF2 (Password-Based Key Derivation Function 2) applies a hash function many times (iterations). It is slower than a single SHA256 but less memory-intensive than Argon2.
PBKDF2 is the default in ASP.NET Core Identity and is FIPS-compliant, which matters in US government and healthcare contexts. It is not the strongest option, but it is widely available and audited.
Recommended configuration: PBKDF2-HMAC-SHA256 with 600,000+ iterations (OWASP 2024 recommendation).
Algorithm Comparison
| Algorithm | Speed | Memory-Hard | Recommended | Notes | |-----------|-------|-------------|-------------|-------| | MD5 | Extremely fast | No | Never | Cryptographically broken for passwords | | SHA1 | Very fast | No | Never | Cryptographically broken for passwords | | SHA256 | Fast | No | Never | Too fast for passwords | | bcrypt | Slow (tunable) | No | Yes | Solid choice, widely supported | | Argon2id | Slow (tunable) | Yes | Best choice | Winner of PHC, most modern | | PBKDF2 | Slow (tunable) | No | Yes (FIPS) | Default in ASP.NET Core, good enough | | scrypt | Slow (tunable) | Yes | Yes | Memory-hard alternative to Argon2 |
Implementing with ASP.NET Core Identity
ASP.NET Core Identity handles password hashing automatically. The default algorithm is PBKDF2 with HMAC-SHA256.
// Program.cs — add Identity services
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
// Identity automatically:
// - Hashes passwords with PBKDF2 on register
// - Verifies hashes on login
// - Handles salts internally
// You never touch raw passwordsTo increase the iteration count (recommended — default is 10,000 which is low):
builder.Services.Configure<PasswordHasherOptions>(options =>
{
options.IterationCount = 600_000; // OWASP 2024 recommendation
});For user registration and login, Identity handles everything:
// Registration — Identity hashes the password
var result = await _userManager.CreateAsync(user, plainTextPassword);
// Login — Identity verifies the hash
var result = await _signInManager.PasswordSignInAsync(
username, plainTextPassword, isPersistent: false, lockoutOnFailure: true);Standalone bcrypt in C# with BCrypt.Net
If you are not using Identity, use the BCrypt.Net-Next NuGet package:
dotnet add package BCrypt.Net-Nextusing BCrypt.Net;
// Hashing a password (cost factor 12 is a good default)
string hashedPassword = BCrypt.HashPassword(plainTextPassword, workFactor: 12);
// Output: "$2a$12$..." — includes the salt and cost factor
// Verifying a password at login
bool isValid = BCrypt.Verify(plainTextPassword, hashedPassword);
if (!isValid)
{
return Unauthorized("Invalid credentials");
}
// Checking if the hash needs to be upgraded (cost factor was increased)
if (BCrypt.PasswordNeedsRehash(hashedPassword, newWorkFactor: 13))
{
var upgraded = BCrypt.HashPassword(plainTextPassword, workFactor: 13);
await UpdateHashInDatabase(userId, upgraded);
}The PasswordNeedsRehash check lets you upgrade work factors incrementally — on each user's next login — without forcing everyone to reset their passwords.
Password Validation Rules — Length Beats Complexity
The old advice was: "require uppercase, lowercase, number, symbol." Research (including NIST SP 800-63B) shows this is largely ineffective. Users satisfy it with Password1! and move on.
Modern NIST guidance:
- Minimum 8 characters, no maximum (or a very high maximum like 256). Passphrases like
correct-horse-battery-stapleare far stronger thanP@ssw0rd!. - Do not require character complexity rules. They push users toward predictable substitutions.
- Do not require periodic password changes. Users just increment a number.
- Do check against known breached passwords (see below).
- Allow paste. Blocking paste prevents password managers, which are the best thing users can do for security.
// Simple but effective validation
if (password.Length < 8)
return "Password must be at least 8 characters.";
if (password.Length > 256)
return "Password is too long.";
// Check against breach database (see below)
if (await IsPasswordBreached(password))
return "This password has appeared in known data breaches. Please choose another.";Checking Against Breach Databases — HaveIBeenPwned API
The Have I Been Pwned (HIBP) API lets you check if a password appears in known data breach databases — without sending the actual password.
It uses k-anonymity: you send the first 5 characters of the SHA1 hash. The API returns all hashes with that prefix. You check locally if the full hash matches.
public async Task<bool> IsPasswordPwned(string password)
{
using var sha1 = SHA1.Create();
var hashBytes = sha1.ComputeHash(Encoding.UTF8.GetBytes(password));
var hash = Convert.ToHexString(hashBytes).ToUpper();
var prefix = hash[..5]; // First 5 characters
var suffix = hash[5..]; // Remaining characters (checked locally)
var response = await _httpClient.GetStringAsync(
$"https://api.pwnedpasswords.com/range/{prefix}");
// Response is a list of "HASH_SUFFIX:COUNT" lines
return response.Split('\n')
.Any(line => line.Split(':')[0].Equals(suffix, StringComparison.OrdinalIgnoreCase));
}The password never leaves your server in full. HIBP never learns the actual password — only the 5-character prefix, which matches thousands of different passwords. This is a well-designed privacy-preserving API.
Use this check at registration and password change time. If the password appears in breach databases with a high count, reject it.
The Complete Checklist
- [ ] Passwords are hashed — never stored in plaintext or reversible encryption
- [ ] Using bcrypt (cost 12+), Argon2id, or PBKDF2 (600,000+ iterations)
- [ ] Not using MD5, SHA1, or unsalted SHA256 for password storage
- [ ] Salt is unique per user (bcrypt and Identity handle this automatically)
- [ ] Cost factors are reviewed annually and increased as hardware improves
- [ ] Password minimum length is at least 8 characters
- [ ] Paste is allowed (password managers work)
- [ ] Breach database check (HIBP) at registration and password change
- [ ] Account lockout after N failed attempts (prevents brute force)
- [ ] MFA available to users (second factor limits damage even if password is compromised)
Password storage is a solved problem. Use the right algorithm with the right parameters, and you have taken care of one of the most impactful things you can do to protect your users.