8.9 KiB
Guide 17: Password Hashing with BCrypt
What is Password Hashing?
When a user creates an account with the password "DemoNurse1!", the application must store something that lets it verify the password later — but it should never store the password itself. If the database is compromised, plaintext passwords would be immediately usable by the attacker.
Hashing converts a password into a fixed-length string of random-looking characters using a one-way mathematical function. "One-way" means you can compute the hash from the password, but you can't compute the password from the hash:
"DemoNurse1!" → hash() → "$2a$12$xK7W3M...long hash string..."
When the user logs in, you hash the submitted password and compare it to the stored hash. If they match, the password is correct — without ever storing the actual password.
Why BCrypt Specifically?
Not all hash functions are created equal. General-purpose hash functions like SHA-256 are designed to be fast — billions of hashes per second on modern hardware. That's a problem for passwords: an attacker who steals the hashed passwords can try billions of guesses per second.
BCrypt is specifically designed for password hashing with two key properties:
-
It's intentionally slow: BCrypt has a configurable "cost factor" (also called "work factor") that controls how many iterations the algorithm performs. Cost factor 12 (the default) means 2^12 = 4,096 iterations, making each hash take ~250ms. Fast enough that a single login is imperceptible, but an attacker trying 1 million passwords would need ~70 hours.
-
It includes a built-in salt: A salt is a random value mixed into the hash. Without a salt, two users with the same password would have the same hash — an attacker could build a precomputed table (a "rainbow table") of common passwords and their hashes, then look up matches instantly. BCrypt generates a random salt for each password and embeds it in the output, so identical passwords produce different hashes.
A BCrypt hash looks like this:
$2a$12$xK7W3MqQ5Z6Y8B9A0C1D2EfGhIjKlMnOpQrStUvWxYz0123456789Ab
│ │ │ │
│ │ │ └── The hash itself
│ │ └── The salt (22 chars)
│ └── Cost factor (12 = 2^12 iterations)
└── Algorithm version
The salt and cost factor are stored right in the hash string, so you don't need a separate column for them.
How BCrypt is Used in This Project
NuGet Package
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
BCrypt.Net-Next is a .NET implementation of the BCrypt algorithm. It provides two key methods: HashPassword() and Verify().
Hashing on Account Creation
When a new user is created, the plaintext password is hashed before storage:
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
{
// Validate the password meets minimum requirements
ValidatePassword(req.Password);
var user = new ClinicalUser
{
Id = Guid.NewGuid(),
Username = req.Username.Trim(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), // hash here
DisplayName = req.DisplayName.Trim(),
Role = role,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
};
_db.ClinicalUsers.Add(user);
await _db.SaveChangesAsync();
return Map(user);
}
HashPassword(req.Password) generates a random salt, applies BCrypt with the default cost factor (12), and returns the full hash string. Each call with the same password produces a different hash (because the salt is random).
Password Validation Rules
private static void ValidatePassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
if (password.Length < 8)
throw new ValidationException(
"Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
}
The minimum length of 8 characters is a baseline. In production, you'd typically also require uppercase, lowercase, digits, and special characters — but the BCrypt hash itself doesn't care about password complexity.
Verification on Login
When a user logs in, the submitted password is verified against the stored hash:
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.ClinicalUsers
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException(
"Invalid username or password.", "INVALID_CREDENTIALS");
// ... generate JWT token
}
How does Verify work?
- Extract the salt and cost factor from the stored hash string
- Hash the submitted password using the same salt and cost factor
- Compare the result to the stored hash
- If they match, the password is correct
Security note: The error message says "Invalid username or password" — it does not distinguish between "user not found" and "wrong password." This prevents an attacker from enumerating valid usernames by observing different error messages.
Seed Data (Development Only)
The user seeder creates demo accounts with hashed passwords:
new ClinicalUser
{
Username = "nurse.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
DisplayName = "Demo Nurse",
Role = ClinicalRole.Nurse,
},
new ClinicalUser
{
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
},
new ClinicalUser
{
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
},
Even in development, passwords are never stored in plaintext in the database. The passwords themselves ("DemoNurse1!" etc.) appear in the seeder code, but they're only used during the initial seeding and don't persist as plaintext anywhere.
Database Schema
builder.Property(u => u.PasswordHash).HasColumnName("password_hash")
.HasMaxLength(500).IsRequired();
HasMaxLength(500) accommodates the BCrypt hash string (typically ~60 characters) with room for future algorithm changes that might produce longer hashes.
Cost Factor Considerations
The default cost factor of 12 is a good balance for 2024-era hardware:
| Cost Factor | Iterations | Approximate Time | Use Case |
|---|---|---|---|
| 10 | 1,024 | ~65ms | Minimum for production |
| 11 | 2,048 | ~130ms | Reasonable for high-traffic APIs |
| 12 | 4,096 | ~250ms | Default — good balance |
| 13 | 8,192 | ~500ms | More security, but login feels slower |
| 14 | 16,384 | ~1s | High-security environments |
The cost factor should be increased over time as hardware gets faster. What takes 250ms today might take 25ms in 10 years. The industry recommendation: choose the highest cost factor that keeps login time under ~500ms for your hardware.
You can customize the cost factor:
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 13);
Existing hashes with a lower cost factor continue to verify correctly — BCrypt reads the cost factor from the hash string.
What BCrypt Does NOT Protect Against
- Weak passwords: BCrypt slows down brute-force attacks, but "password123" will still be cracked quickly. Enforce password complexity rules at the application level.
- Phishing: If a user gives their password to an attacker directly, hashing doesn't help.
- Memory dumps: While the application is running, the plaintext password exists briefly in memory (during the Verify call). In extremely sensitive environments, you'd use secure memory handling.
- Credential stuffing: If a user reuses their password from another breached site, BCrypt can't help. Multi-factor authentication (MFA) addresses this.
Key Takeaways
- Never store plaintext passwords — always hash them before writing to the database. There is no valid reason to store or log a user's actual password.
- BCrypt is purposefully slow — the cost factor makes brute-force attacks impractical while keeping legitimate logins fast
- Each hash includes its own salt — even identical passwords produce different hashes, defeating rainbow table attacks
- The cost factor is embedded in the hash — you can increase the cost factor for new passwords without invalidating existing ones
- Give generic error messages — "Invalid username or password" prevents username enumeration. Never reveal whether the username or the password was wrong.
- BCrypt handles the hard parts — salt generation, iteration count, and comparison are all managed by the library. You call
HashPassword()andVerify()— nothing else.