Under the Hood
Crypto

Password hashing: salts and slow KDFs

Storing passwords safely means hashing them, but not the way the last lesson might suggest — a fast general-purpose hash is exactly the wrong tool, and safety instead comes from a unique salt per password and a deliberately slow, memory-hard function that makes guessing expensive.

Password hashing: salts and slow KDFs

Lesson 2 ended on a cliffhanger: a hash is one-way, so it looks like the obvious way to store a password — hash it, store the digest, and verify later by hashing the login attempt and comparing. That reasoning gets the "one-way" part right and the rest badly wrong. SHA-256 is one-way in the sense that matters for integrity checking, but it's also fast — and fast is precisely the property you don't want when the "message" being hashed is something an attacker can guess, one candidate password at a time, at billions of guesses per second.

The frame: passwords must never be stored in plaintext, but a plain fast hash is the wrong fix — you need a per-password salt to defeat precomputed attacks, and a deliberately slow, memory-hard function to make brute force expensive even on specialized hardware.

Why plaintext is the obvious disaster

Store passwords as-is, and a single database breach hands the attacker every user's actual password — not "an obstacle to overcome," just the password, ready to try on that same user's email, bank, and every other account where they reused it. This is why "we hash your password" is table stakes, not a nice-to-have. But the next question is which hash, and how — and that's where a lot of real systems still get it wrong.

Why a plain fast hash is exactly wrong

Suppose you store SHA-256(password) for every user. Two problems, and they compound.

Speed. SHA-256 is engineered to be fast, because its normal jobs — file integrity, Git object addressing — process large amounts of data and shouldn't be slow. That same speed lets an attacker who steals the hash table run an offline brute-force: a modern GPU computes billions of SHA-256 hashes per second, trying "password1", "password2", every word in a dictionary, every leaked password from other breaches, until one matches. The one-wayness of SHA-256 is real — you can't algebraically invert it — but if guessing is cheap enough, "you can't invert it" stops mattering.

Rainbow tables. Because a plain hash of the same input always produces the same digest (that's determinism, the very first property from lesson 2), an attacker can precompute a giant table mapping common passwords — and their hashes — once, then reuse that table against any leaked database, forever. These precomputed lookup tables are called rainbow tables, and building one is a one-time cost the attacker amortizes across every victim they'll ever target. Your users pay for that precomputation the moment your database leaks, even if your own systems were never touched again.

Both problems point at determinism and speed as the culprits — which tells you exactly what to fix.

Salt: making every hash unique

A salt is a random value, generated fresh for each password, stored alongside the resulting hash (it doesn't need to be secret — just unique and unpredictable). Before hashing, the salt is mixed into the input: instead of storing hash(password), you store hash(password + salt) and the salt itself, side by side.

Salting does two things at once. First, two users with the identical password "correcthorse" now get different stored hashes, because each got a different random salt — an attacker glancing at the database can no longer even tell which users share a password. Second, and more importantly, it kills rainbow tables outright: a precomputed table maps unsalted passwords to hashes, but every entry in your database now effectively needs its own table, computed with its own salt. Precomputing a table per possible salt is exactly as expensive as attacking each password individually from scratch — the "precompute once, reuse forever" economics collapse.

// Generating a random salt in the browser
const salt = crypto.getRandomValues(new Uint8Array(16));
// store this alongside the resulting hash — it isn't secret

Pepper: a secret the database doesn't hold

A pepper is an additional value mixed in the same way as a salt, except it's not stored in the database at all — it lives outside it, in application configuration, an environment variable, or a hardware security module. The idea is narrow but useful: if an attacker steals only the database (a SQL injection, a backup left exposed), they get salts and hashes but not the pepper, and can't run their offline attack at all without also compromising the application layer separately. It's a defense-in-depth addition, not a replacement for salting — most systems skip it, and it only helps if the pepper is genuinely stored somewhere a database-only breach can't reach.

The real fix: slow, memory-hard KDFs

Salting stops precomputation, but it does nothing about raw speed — an attacker who wants one specific user's password still gets to try billions of guesses per second against that one salt, unless the hashing itself is made expensive. That's the job of a key derivation function (KDF) built for passwords: bcrypt, scrypt, and Argon2 (the current recommendation, and the winner of the 2015 Password Hashing Competition).

These functions compute a digest the same conceptual way a hash does, but deliberately take tens to hundreds of milliseconds per call, and Argon2 and scrypt also deliberately consume a large, tunable amount of memory. That memory cost is what "memory-hard" means: it's specifically aimed at GPUs and ASICs, which get their raw speed from running huge numbers of cheap, memory-light operations in parallel. Force each guess to touch a large block of memory, and you take away the very thing that makes those chips fast for a plain hash — you can't just add more parallel compute lanes if each one needs its own large memory footprint.

Every one of these functions exposes a work factor (bcrypt calls it a cost parameter; Argon2 lets you tune time, memory, and parallelism separately) that controls exactly how slow and how memory-hungry each computation is. That's a deliberate dial, not a fixed constant: as hardware gets faster, you raise the work factor, and the same login that took 100ms in 2020 might take 100ms in 2030 running on much faster silicon, because you turned the dial up to compensate. Slowness here is only a problem for someone trying billions of guesses; a real login pays the cost exactly once.

// Conceptual — a real implementation uses a vetted library
// (bcrypt, argon2 npm packages, or the Argon2 spec directly)

function hashPassword(password) {
  const salt = generateRandomSalt();
  const workFactor = 12; // tunable — raise as hardware improves
  const hash = argon2(password, salt, workFactor);
  return { salt, hash, workFactor }; // store all three
}

function verifyPassword(password, stored) {
  const candidateHash = argon2(password, stored.salt, stored.workFactor);
  return constantTimeEqual(candidateHash, stored.hash);
}

Verifying a login

Checking a login attempt never involves decrypting anything — there's nothing to decrypt, because this was never encryption. Verification re-runs the exact same one-way computation and compares outputs:

  1. Look up the stored salt and work factor for that user.
  2. Run the submitted password through the same KDF, with that stored salt and work factor.
  3. Compare the result to the stored hash.

That comparison in the last step has to be constant-time — it must take the same amount of time to run regardless of how many leading bytes match, rather than a naive comparison that returns as soon as it finds the first mismatched byte. A naive early-exit comparison leaks information through timing: an attacker measuring response times can, in principle, recover the hash one byte at a time by noticing which guessed byte makes the comparison take marginally longer. Vetted libraries handle this for you; it's one more reason not to hand-roll the comparison yourself.

Why not encrypt passwords instead?

Encryption is reversible by design — that's the entire feature, decrypting with the right key recovers the original plaintext. Applied to passwords, that reversibility is a liability, not a convenience: if the encryption key is ever stolen alongside the database (and if it's stored anywhere the application can reach it to encrypt/decrypt on demand, it's reachable by an attacker who compromises that application), every password decrypts instantly, in one step, for every user at once. A one-way KDF has no equivalent single point of failure — there's no key whose theft undoes the whole scheme, because there was never a reverse direction to begin with. You want a function nobody can run backward, not a lock with a key sitting nearby.

Where this goes next

Password storage takes a one-way function and adds exactly two things it was missing — per-password salt against precomputation, and deliberate slowness against brute force. That combination is specific to "the input is a guessable secret," which is a different problem from protecting bulk data in transit or at rest. That's where symmetric encryption picks up next: a shared key, a cipher, and the guarantee of confidentiality that hashing was never designed to provide.

Go deeper

  • OWASP — Password Storage Cheat Sheet The industry-standard, actively maintained checklist for exactly this problem, including current recommended Argon2/bcrypt/scrypt parameters.
  • RFC 9106 — Argon2 Memory-Hard Function The formal specification of Argon2, including why it's memory-hard and how the time/memory/parallelism parameters interact.
  • Crypto 101 Covers the history of password-cracking attacks (rainbow tables, GPU cracking) that motivate every design choice in this lesson.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does storing SHA-256(password) fail even though SHA-256 is a secure, one-way hash function?
  2. Explain what a rainbow table is and exactly why a per-password salt makes one useless against your database.
  3. What's the difference between a salt and a pepper, in terms of where each is stored and what threat each defends against?
  4. What does 'memory-hard' mean, and why does it specifically blunt GPU and ASIC attacks rather than just slowing down a regular CPU?
  5. Name the three password KDFs mentioned and which one is currently recommended.
  6. Walk through the verification steps for a login attempt, and explain why the final comparison must be constant-time.
  7. Why is encryption the wrong tool for password storage, even though it's also reversible with the right key — what single point of failure does it introduce that a KDF doesn't have?