Hash Notes — A Hashing & Cryptography Field Guide

Passwords & Authentication

Salting & Password Hashing

Why passwords need a unique random salt and a deliberately slow hash — rainbow tables, peppers, work factors, and the code for doing it right with Argon2id and bcrypt.

You never store passwords — you store something that lets you check a password. At signup, hash it and save the hash. At login, hash the attempt and compare. If the database leaks, the attacker gets hashes, not passwords.

That’s the theory. In practice, a plain sha256(password) falls apart immediately, because passwords are short and predictable. Defending them takes three layers: a salt, a slow hash, and optionally a pepper.

The attacks you’re defending against

  • Precomputed tables / rainbow tables: hash the 10 million most common passwords once, then look up every leaked hash in the table. Instant, and reusable against every database that used the same unsalted hash.
  • Duplicate detection: in an unsalted table, everyone with the same password has the same hash. Crack one, crack them all — and the most common hash is obviously 123456.
  • Brute force / dictionary attacks: for each hash, try billions of candidates (wordlists, leaked passwords, mutation rules like P@ssw0rd!). The only limit is how fast the hash is.

Salt: a unique random value per password

A salt is a random value (16 bytes is standard) generated fresh for each user and stored next to the hash, in plain sight. You hash salt + password instead of password.

Fig. 1 — Unsalted vs. salted — what an attacker with a precomputed table sees
usersaltstored hashprecomputed-table lookup

0 of 4 cracked instantly by looking the hash up in a table of common-password hashes, and alice and carol's identical hashes reveal they share a password. One precomputed table attacks every user in every leaked database at once.

What salt does:

  • Kills precomputed tables — the attacker would need a separate table for every possible salt.
  • Hides duplicates — alice and carol both use hunter2 but get unrelated hashes.
  • Forces per-user work — cracking a million hashes costs a million times as much as cracking one.

What salt does not do: make an individual weak password harder to guess. With the salt in hand (it’s in the same row), the attacker can still try hunter2 against alice’s hash at billions of guesses per second if the hash is fast. That’s the next layer.

Slow hashing: make every guess expensive

General-purpose hashes like SHA-256 are designed to be fast. For passwords you want the opposite. Password hashing functions (a.k.a. key-derivation functions) have a tunable work factor:

Function Tunable cost Notes
Argon2id time, memory, parallelism Winner of the 2015 Password Hashing Competition. OWASP’s first choice. Memory-hardness defeats GPUs/ASICs, which have lots of cores but little memory per core.
scrypt CPU/memory cost (N), r, p Also memory-hard. Good second choice.
bcrypt cost (log₂ rounds) Since 1999, available everywhere. Not memory-hard. Truncates input at 72 bytes.
PBKDF2 iteration count Required for FIPS-140 compliance. Weakest of the four against GPUs.

Tune to your hardware: pick parameters so one hash takes roughly 100 ms – 1 s on your server. Your users won’t notice once per login; an attacker trying billions of guesses will. OWASP’s minimums (2024): Argon2id with m=19 MiB, t=2, p=1; bcrypt cost ≥ 10; PBKDF2-HMAC-SHA256 with 600,000 iterations.

All four produce a self-describing string that stores the algorithm, parameters, salt, and hash together — you store one column:

$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$9Ib3Zb8a7...
 └ algo   └ ver └ memory/time/parallelism └ salt (b64)  └ hash (b64)

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 └ bcrypt └ cost=12 (2¹² rounds) └ 22-char salt + 31-char hash

Doing it right in code

Don’t implement this yourself. Use the library, which handles salt generation, encoding, and constant-time comparison:

# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()                      # sensible Argon2id defaults

stored = ph.hash("correct horse battery staple")   # salt generated for you
try:
    ph.verify(stored, attempt)                     # raises on mismatch
    if ph.check_needs_rehash(stored):              # parameters were raised since this hash was made
        stored = ph.hash(attempt)                  # upgrade transparently at login
except VerifyMismatchError:
    deny()
// npm install bcrypt   (or argon2)
import bcrypt from "bcrypt";
const stored = await bcrypt.hash(password, 12);     // cost 12
const ok = await bcrypt.compare(attempt, stored);  // reads salt + cost from `stored`
// Spring Security
PasswordEncoder encoder = new Argon2PasswordEncoder(16, 32, 1, 19456, 2);
String stored = encoder.encode(rawPassword);
boolean ok = encoder.matches(attempt, stored);

Pepper: a secret the database doesn’t have

A pepper is a secret key, the same for all users, stored outside the database (in an HSM, KMS, or app secret). The usual approach: HMAC-SHA256(pepper, password) and then Argon2 the result, or encrypt the final hash with the pepper key. If only the database leaks (SQL injection, a stolen backup), the attacker can’t crack anything without the pepper.

Trade-off: rotating a pepper is hard (you can’t re-hash without the original passwords), so it’s an optional extra layer, not a replacement for salt + slow hash.

Gotchas