Passwords
In short: Passwords are stored as a slow salted hash (bcrypt or argon2id), not encrypted and not with fast SHA-256, so a database leak does not give away the passwords themselves.
Problem
The backup that went public
The shop keeps its buyers in a users table: an email and a password. Somebody uploads a database backup to a public folder, and a week later it is on the internet. What the buyers lose depends only on how the password was stored:
- as text: every password is out in the open. Many people use one password everywhere, so their email accounts leak too;
- encrypted with a key that sits next to the database: the same. The key is in the configuration of the same server and leaks with it;
- as SHA-256 with no salt: the passwords are not visible, but equal passwords give equal hashes. The table itself shows who shares a password with whom. And whoever has the table can take a list of common passwords, hash each one and look for matches: that is a dictionary attack, and SHA-256 was built to be fast.
A leak can never be ruled out completely. So the password is stored in a way that a leak does not hand out the passwords.
The idea
Keep the fingerprint, not the password
The server does not need to know the password. It only has to check whether the typed password is the same one. That is what a cryptographic hash is for: a function that turns text into a fingerprint of fixed length and cannot be reversed. The same input gives the same fingerprint, and the fingerprint does not lead back to the input.
This is not theGetHashCode from the Dictionary lesson: that one is fast, short and collides often, because it is made for buckets. A cryptographic hash is a different kind.The shop's old code keeps the SHA-256 of the password:
static string Sha256(string text) => Convert.ToHexStringLower( SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// The shop's old code: SHA-256 without a salt.static Row Register(string email, string password) => new(email, "", Sha256(password));Four buyers; ana and vera have the same password. The function SharedRows counts the rows whose hash appears more than once:
static int SharedRows(List<Row> users) => users.GroupBy(row => row.Hash) .Where(group => group.Count() > 1) .Sum(group => group.Count());List<Row> users =[ Register("ana@example.com", "qwerty"), Register("boris@example.com", "sofia2024"), Register("vera@example.com", "qwerty"), Register("georgi@example.com", "Kp7#vR!q2Lm9"),];Console.WriteLine("The users table:");foreach (var row in users) Console.WriteLine($"{row.Email,-20}{row.Hash[..16]}…");Console.WriteLine($"Rows that share a hash: {SharedRows(users)}");The users table:ana@example.com 65e84be33532fb78…boris@example.com 6191711a1871a6bd…vera@example.com 65e84be33532fb78…georgi@example.com 9cbd9835924cf733…Rows that share a hash: 2The rows of ana and vera are equal. Without breaking anything, anyone with the table sees that the two have one password. If one of them gives it away, she gives away the other's too.
The idea
A salt: every row different
The fix for equal rows is a salt: random bytes, different for every row, that go next to the password before the hash. The salt is not a secret. It sits openly in the row, because the login needs it again.
// A random salt of its own for every row.static Row Register(string email, string password){ string salt = Convert.ToHexStringLower( RandomNumberGenerator.GetBytes(16)); return new(email, salt, Sha256(salt + password));}RandomNumberGenerator is a cryptographic random number generator: its next value cannot be predicted. (Why the ordinary generator will not do is in the cryptography lesson.)
The salt is different on every run, so the program prints only what does not change:
List<Row> users =[ Register("ana@example.com", "qwerty"), Register("boris@example.com", "sofia2024"), Register("vera@example.com", "qwerty"), Register("georgi@example.com", "Kp7#vR!q2Lm9"),];Console.WriteLine($"Same record for ana and vera: {users[0].Hash == users[2].Hash}");Console.WriteLine($"Rows that share a hash: {SharedRows(users)}");Same record for ana and vera: FalseRows that share a hash: 0Equal passwords no longer show, and one computation counts for one row only. But SHA-256 is still fast: a graphics card computes billions a second, so the dictionary attack still works, just row by row. The hash also has to be slow on purpose.
See it
The users table: no salt, a salt, a slow hash
// demo; on a real server 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( expected, actual);}static string Sha256(string text) => Convert.ToHexStringLower( SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// The shop's old code: SHA-256 without a salt.static Row Register(string email, string password) => new(email, "", Sha256(password));// A random salt of its own for every row.static Row Register(string email, string password){ string salt = Convert.ToHexStringLower( RandomNumberGenerator.GetBytes(16)); return new(email, salt, Sha256(salt + password));}// demo; on a real server 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( expected, actual);}Step by step, in words
- The users table with the shop's old code: an email and the SHA-256 of the password, with no salt.
- Equal passwords give equal hashes. ana and vera have the same value, so the table itself shows who shares a password: 2 rows.
- Now every row gets a random salt of its own, and the row keeps SHA-256(salt + password).
- Every row is different, ana and vera too: 0 rows share a hash. But SHA-256 is fast, so each guess at a row is still cheap.
- The fix is a slow hash: PBKDF2 repeats HMAC-SHA256 10,000 times here (a demo). On a real server it is 600,000.
- The record keeps everything needed to check it later: algorithm $ cost $ salt $ hash. The password itself is not in it.
- At login the code reads the cost and the salt from the record and hashes the typed password the same way.
- The two hashes are compared in constant time: "qwerty" gives ✓, "qwerty1" gives ✗. Equal passwords no longer show, and every guess is slow.
The last frame puts the three steps together: the column keeps a record with a salt and a cost, no two rows are equal, and the login checks the password against the record.
The idea
A slow hash and the record
A slow password hash repeats its work many times. How many times is the cost (work factor). A login computes it once and waits a fraction of a second. Whoever has the leaked table pays the same price for every guess.
The standard library has PBKDF2: HMAC-SHA256, repeated as many iterations as you say.static byte[] Kdf(string password, byte[] salt, int iterations) => Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32);Rfc2898DeriveBytes.Pbkdf2 is a static method since .NET 6: password, salt, iterations, algorithm and length in bytes (32).The record keeps everything the check needs: algorithm, cost, salt and hash, with $ between them. That way the cost can go up later without breaking the old records.
// demo; on a real server 600_000 (OWASP)const int Iterations = 10_000;
static string HashPassword(string password){ byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] hash = Kdf(password, salt, Iterations); return $"pbkdf2_sha256${Iterations}$" + $"{Convert.ToBase64String(salt)}$" + Convert.ToBase64String(hash);}
static bool Verify(string password, string record){ string[] parts = record.Split('$'); int iterations = int.Parse(parts[1]); byte[] salt = Convert.FromBase64String(parts[2]); byte[] expected = Convert.FromBase64String(parts[3]); byte[] actual = Kdf(password, salt, iterations); return CryptographicOperations.FixedTimeEquals( expected, actual);}string record = HashPassword("qwerty");string[] parts = record.Split('$');Console.WriteLine($"Algorithm and cost: {parts[0]}, {parts[1]}");Console.WriteLine($"Log in with the right password: {Verify("qwerty", record)}");Console.WriteLine($"Log in with a wrong password: {Verify("qwerty1", record)}");Console.WriteLine($"One password, two records, equal: {record == HashPassword("qwerty")}");Algorithm and cost: pbkdf2_sha256, 10000Log in with the right password: TrueLog in with a wrong password: FalseOne password, two records, equal: FalseThe cost in the program is small, so that it runs fast. On a real server OWASP recommends 600,000 iterations for PBKDF2-HMAC-SHA256. In a real project you usually do not write this yourself but use bcrypt or argon2id from a proven library (details under "More").
Try it
Predict the output
One and the same password, hashed twice. What will it print?
string first = HashPassword("qwerty");string second = HashPassword("qwerty");Console.WriteLine(first == second);Console.WriteLine(Verify("qwerty", first));Console.WriteLine(Verify("qwerty", second));Every record has a random salt of its own, so the two records are different. The check reads the salt from the record, so "qwerty" passes for both.
Try it
Fill in
At login the two hashes are compared. An ordinary comparison stops at the first different byte, and the response time can reveal how far they match. Which comparison always goes through all the bytes?
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.(
expected, actual);CryptographicOperations.FixedTimeEquals compares all the bytes, wherever the first difference is.Try it
Find the bug
A colleague "simplifies" the code: instead of a random salt for every row, one salt for all. The slow hash is the same. Where is the problem?
Pick the line with the bug.
One salt for all rows is as good as no salt: equal passwords still give equal records, and one computation still counts for the whole table. The salt must be random and different for every row, created on every call to HashPassword.
See it
The cost grows over the years
Computers get faster, so a cost that was enough last year is not enough some time later. Old records cannot be recomputed: a hash does not go backwards, and the server has the password only at the moment of a login. So the new hash happens on a successful login. Here HashPassword takes the cost as a parameter, and every record keeps its own, so the old records go on working.
// last year it was 10_000const int Iterations = 20_000;
static int CostOf(string record) => int.Parse(record.Split('$')[1]);
static bool Login(Account account, string password){ if (!Verify(password, account.Record)) return false; if (CostOf(account.Record) < Iterations) { // only now do we hold the password: hash it at the new cost account.Record = HashPassword(password, Iterations); } return true;}var account = new Account(HashPassword("qwerty", 10_000));Console.WriteLine($"Cost before the login: {CostOf(account.Record)}");Console.WriteLine($"Log in: {Login(account, "qwerty")}");Console.WriteLine($"Cost after the login: {CostOf(account.Record)}");Cost before the login: 10000Log in: TrueCost after the login: 20000More precisely, if you are curious
Which settings (OWASP, Password Storage Cheat Sheet):
- argon2id: 19 MiB of memory, 2 iterations, parallelism 1. The first choice for a new project;
- scrypt: N = 2¹⁷, r = 8, p = 1. It needs 128 MiB of memory, and in both Python and Node the default limit is 32 MiB, so it is raised with
maxmem; - bcrypt: cost 10 or more. It uses only the first 72 bytes of the password;
- PBKDF2-HMAC-SHA256: 600,000 iterations, when a FIPS-approved algorithm is required.
What real projects use. You rarely write this yourself; you use the framework's library:
ASP.NET Core Identity has PasswordHasher<TUser>: PBKDF2 with the salt and cost in the record, and PasswordVerificationResult.SuccessRehashNeeded, which tells you exactly when to hash again. For argon2id and bcrypt there are NuGet packages.
Pepper. Some also add a secret value, shared by all rows, that lives outside the database (in a secret store, see the secrets lesson). A leak of the database alone is then not enough. It helps, but it does not replace the salt and the slow hash.
The password policy. A long password (at least 12 characters) and a check against lists of leaked passwords help more than "a capital letter and a digit" rules. That is also the exercise below.
How it is in the other languages: Java and C# have PBKDF2 in the standard library but no scrypt; Python and Node have both (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). None of the four has argon2id or bcrypt built in.
Exercise
Changing the password
Solve it in your editor.
The buyer's account has the password "qwerty". Write ChangePassword(account, oldPassword, newPassword), which:
- checks the current password with
Verifyand refuses if it is wrong; - refuses a new password shorter than 12 characters;
- refuses a new password that (ignoring upper and lower case) is in the list
password1234,qwerty123456,123456789012; - otherwise stores a new record with a new salt and returns "Password changed".
Call it with ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") and ("qwerty", "tram-9-to-vitosha"), then check the login with the new password:
Wrong current passwordThe new password is shorter than 12 charactersThis password is on the lists of leaked passwordsPassword changedLog in with the new password: TrueMore tasks
- A new hash at login. Add
Loginfrom "The cost grows over the years" and check that an old account with a lower cost comes out of the login with the new one. - The same time for a wrong email. If the email does not exist, the login answers at once; if it does, it computes the slow hash. How would you make both cases take the same time?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Hash codes and hash functions · Lambdas and delegates · LINQ · record, switch and patterns
- Continues in: Sessions, cookies and CSRF · JWT and its traps · Cryptography you don't write yourself
- Tasks on this topic: Security for developers