Password Security
By the end of this lesson
Store credentials with a suitable password hashing function.
Password storage is designed around an assumption that sounds pessimistic and is only realistic: one day, someone may read your employee table.
It does not take a break-in. A backup restored onto a laptop, an over-permissioned reporting account, a misconfigured storage bucket, a developer copy of production data. Your database is rarely the only copy of itself.
So the question is not whether the table can be read. It is what the reader can do with it. Everything in this lesson exists to make turning stored credentials into working logins expensive, per account, including the accounts with unremarkable passwords.
Four ways credentials get stored, and what each one hands over when the table leaks:
- Plaintext
- Every account, immediately, with no mitigating detail and no recovery position. It also means anyone with read access to the database — staff, contractors, a support tool — can read passwords that people have reused elsewhere.
- Reversible encryption
- Every account, as soon as the key is found. Keys live in configuration, in the same deployment, often in the same backup as the data. Encryption is the right tool when you need the value back; for a password you never need the value back, so it adds exposure without adding anything.
- A fast general-purpose hash (MD5, SHA-1, SHA-256)
- Common and weak passwords fall quickly, because candidates can be tested offline at enormous speed. Adding a salt stops two identical passwords producing identical stored values, and stops precomputed lookup tables. It does not slow down a single guess, which is the thing that matters here.
- A password hashing function (Argon2, bcrypt, scrypt, PBKDF2 with a high iteration count)
- Each guess costs measurable time, and for some of them, memory. Weak passwords are still weak — nothing fixes 'password1' — but everything else becomes expensive to attack at scale. This is the only option on this list to choose for new work.
Why SHA-256 is the wrong tool for this
SHA-256 is a good hash function. It is fast, and speed is the point of it: you want to checksum a large file or verify a download without waiting around. Hardware built for hashing computes an enormous number of them per second.
Now count the hashes each side needs. Verifying one login needs one. Working through a large list of candidate passwords against a stolen table needs billions. Speed buys you almost nothing and buys the other party everything, so choosing a fast hash optimises for the wrong party. That is the entire argument, and it does not depend on any weakness in SHA-256 itself.
A password hashing function inverts the economics on purpose. It is deliberately slow, and the slowness is tunable through a work factor — an iteration count, a memory cost, or both. Set it so one verification takes a fraction of a second on your own servers. Nobody notices that during login, and the same cost multiplies across every guess anybody makes against the stored values.
The trade-off is real and lands on you. That cost is CPU and, for Argon2 and scrypt, memory, on your own machines, on a login endpoint that may face a burst of traffic. Tune the work factor against your hardware and measure the result rather than copying a figure from an article, and re-measure when you change instance size. A setting chosen two years ago on different hardware is not the setting you would choose today.
Each password also needs its own random salt — a value mixed in before hashing so that two people who chose the same password do not end up with the same stored hash. You will rarely handle it yourself. bcrypt, Argon2 and the ASP.NET Core implementation all generate a salt per password and store it inside the string they hand back. Writing your own salt handling is a chance to get it wrong in exchange for nothing.
using Microsoft.AspNetCore.Identity;
public sealed class EmployeeAccountService
{
// Generates a random salt per password, and records the algorithm,
// its parameters and the salt inside the string it returns.
private readonly PasswordHasher<Employee> _hasher = new();
private readonly IEmployeeRepository _employees;
public EmployeeAccountService(IEmployeeRepository employees) => _employees = employees;
public async Task RegisterAsync(Employee employee, string password, CancellationToken ct)
{
employee.PasswordHash = _hasher.HashPassword(employee, password);
await _employees.AddAsync(employee, ct);
}
public async Task<bool> SignInAsync(string email, string password, CancellationToken ct)
{
var employee = await _employees.FindByEmailAsync(email, ct);
if (employee is null)
{
return false;
}
var result = _hasher.VerifyHashedPassword(
employee, employee.PasswordHash, password);
if (result == PasswordVerificationResult.SuccessRehashNeeded)
{
// Correct password, stored with older parameters. Upgrade it now:
// this request is the only time the plaintext is available.
employee.PasswordHash = _hasher.HashPassword(employee, password);
await _employees.UpdateAsync(employee, ct);
}
return result != PasswordVerificationResult.Failed;
}
}- HashPassword returns a single string containing a format marker, the parameters, the salt and the derived hash. Store it in one column. There is no separate salt column to design, migrate or accidentally share.
- VerifyHashedPassword reads the parameters back out of the stored string, which is why a hash written by an older version of the library still verifies after an upgrade.
- SuccessRehashNeeded means the password was right but was stored with weaker settings than the current ones. Rehashing here is the only opportunity you get, because the plaintext exists in memory during this request and nowhere else.
- Both failure paths return the same false, so the response does not confirm which email addresses have accounts. Keep the message the client sees identical too — 'those details did not match' rather than 'no such user'.
- One gap worth knowing about: the unknown-email path returns without hashing anything, so it answers measurably faster than a wrong password does. If that difference matters for your application, verify the supplied password against a fixed dummy hash before returning, so both paths cost the same.
- PasswordHasher uses PBKDF2 with HMAC-SHA512 and a per-password salt by default, with an iteration count that has been raised across .NET releases. Check the current default for the version you are on, and set IterationCount through PasswordHasherOptions if you want to control it yourself. A dedicated bcrypt or Argon2 library is an equally reasonable choice; the important thing is that it is one of these four families and not a hash you assembled.
Upgrading the algorithm or its parameters later, without locking anybody out:
Confirm your stored hashes describe themselves
Any usable password hashing library writes the algorithm and its parameters into the stored string. That is what lets old and new formats sit in the same column while a migration is in progress. If your stored values do not carry their parameters, that is the first thing to fix.
Raise the setting for new hashes
Change the configuration and deploy. Every password set from this point uses the new cost. Nothing existing changes yet, and nothing breaks, because verification reads each stored value's own parameters.
Rehash on the next successful sign-in
You cannot rehash a stored hash directly — you do not have the password, which is the point of the design. The only moment the plaintext exists is during a successful sign-in, so that is when you replace the stored value. Over a few weeks, most active accounts migrate on their own.
Decide what to do about dormant accounts
Accounts that never sign in keep the old hash indefinitely, and no amount of waiting fixes them. If the old scheme was merely under-tuned, leaving them is a defensible risk to accept knowingly. If it was a fast hash or had no salt, the honest options are a forced reset for those accounts, or rehashing the old hash with the new function and recording that it is double-wrapped. The second avoids disrupting users and leaves you maintaining two verification paths for as long as those rows exist.
Write down what you chose and when
A short note in the repository saying which function, which parameters, when they were last reviewed and on what hardware. Without it, the next person cannot tell a deliberate setting from a forgotten one.
Summary
- Assume the credential table may be read one day, and design so that reading it is not the same as having the accounts
- Never store plaintext, never store reversibly encrypted passwords, and never store a plain fast hash
- A fast hash such as SHA-256 is the wrong tool because speed helps offline guessing far more than it helps you
- Use Argon2, bcrypt, scrypt or PBKDF2 with a high iteration count, and let the library handle the per-password salt
- Tune the work factor on your own hardware, and rehash on the next successful sign-in when you raise it
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Why is a salt not enough?
A colleague proposes SHA-256 with a long random salt per password, and argues that the salt makes precomputed tables useless, so the storage is sound.
The first half of that argument is correct. Explain what the salt does not do, and why that is the half that matters.
Show solution
The salt does exactly what your colleague says. Precomputed tables are built against unsalted hashes, so a per-password salt makes them worthless, and it stops two accounts with the same password showing the same stored value.
What it does not do is make one guess cost anything. Testing a candidate against a salted SHA-256 hash means hashing the candidate with that salt — the same single, fast operation. The salt forces the work to be redone per account; it does not make the work expensive.
That is the distinction to hold on to. A salt changes the shape of the attack from 'one pass over all accounts' to 'one pass per account'. A work factor changes the price of every single guess. You want both, and only the second is missing from the proposal.
Challenge
Pick a work factor for your own hardware
Time a single password verification on the machine your application actually runs on, at a few different work factor settings. Use a plain stopwatch around one verification call, repeated enough times to get a stable figure.
Then choose a setting, and write down the reasoning where the next person will find it.
Show solution
There is no universal number, which is why this is a measurement rather than a lookup. The useful shape of the answer is a target duration per verification on your hardware, commonly somewhere in the low hundreds of milliseconds for an interactive login.
Two constraints pull against each other. Higher is better against offline guessing. Higher also multiplies the CPU your login endpoint consumes, and logins arrive in bursts — Monday morning, or right after a deployment that invalidated sessions. Work out what a realistic peak costs you before committing.
Write down the figure, the date, and the instance type you measured on. That turns a future review into a five-minute re-measurement instead of an argument, and it stops a deliberate choice being read as an oversight.
Saved in this browser only.