Password Storage & Hashing

intermediate passwords hashing bcrypt argon2 salt

Someone signs up with a password. We need to check it again next time they log in — but we must never be able to read it back. This tension is the whole topic, and it’s a favourite interview question because so many people get it subtly wrong.

Rule zero — never store plaintext

If our database stores raw passwords and it leaks, every account is instantly owned. Worse, people reuse passwords, so we just handed the attacker their email and bank too. In simple language — we should never be able to see a user’s password, not even us.

So instead of storing the password, we store a one-way fingerprint of it.

Hashing vs encryption — one-way vs reversible

People mix these up constantly, so let’s nail it.

  • Encryption is reversible. We scramble data with a key, and with that key we can unscramble it back. Good for data we need to read later (credit card numbers we charge again).
  • Hashing is one-way. We run the input through a function that produces a fixed-size digest, and there’s no “un-hash”. Given the hash, we can’t get the password back — we can only hash a guess and compare.

Passwords want hashing, not encryption. We never need the original back; we only need to check “does this login attempt hash to the same thing we stored?” Encryption is the wrong tool here — a stolen key would reverse everything.

Why fast hashes (MD5, SHA-256) are bad here

MD5 and SHA-256 are cryptographic hashes, so surely they’re fine? No — and this trips people up. They’re built to be fast, which is exactly what we DON’T want for passwords.

An attacker with a leaked hash database just guesses. A modern GPU can compute billions of SHA-256 hashes per second. So they run every word in the dictionary, every common password, every leaked password from past breaches, hash each one, and compare. Fast hash = fast guessing.

There’s a second problem: fast hashes are deterministic and unsalted by default. md5("password123") is always the same string. So attackers precompute giant lookup tables — rainbow tables — mapping common hashes back to their password, and a “crack” becomes a single table lookup.

Salt — kills rainbow tables

A salt is a random value we generate per user and mix into the password before hashing. We store the salt right next to the hash (it’s not a secret).

Now hash("password123" + saltA) and hash("password123" + saltB) are completely different. So:

  • Two users with the same password get different hashes — no more “oh, these 400 accounts share a hash”.
  • Precomputed rainbow tables are useless, because the attacker didn’t know our random salt ahead of time.

The salt doesn’t need to be hidden. Its whole job is to be unique and unpredictable per user, forcing the attacker to crack every account separately instead of all at once.

Pepper — the salt’s secret cousin

A pepper is like a salt, but it’s a single secret value shared across all users and stored outside the database (in an env var or secret manager, not the DB). The only difference from salt is where it lives and that it’s secret.

So if just the database leaks but the app server’s pepper doesn’t, the attacker is still stuck — they’re missing an ingredient. It’s a nice defence-in-depth layer, but it’s optional; salt + a slow hash is the non-negotiable part.

Slow, adaptive hashes — bcrypt, scrypt, argon2

The real fix for “GPUs guess too fast” is to use a hash that’s deliberately slow. These functions bundle salting in and add a tunable work factor (also called cost or rounds) that controls how much CPU/memory each hash costs.

  • bcrypt — battle-tested, has a cost factor (each +1 doubles the work). Safe default choice.
  • scrypt — also memory-hard, so it resists GPU/ASIC attacks that have lots of cores but limited RAM.
  • argon2 — the modern winner (Password Hashing Competition, 2015), tunable on time, memory, and parallelism. OWASP’s current top recommendation.

The work factor is the key idea. We tune it so one hash takes ~250ms on our server — barely noticeable at login, but it turns “billions per second” into “a handful per second” for the attacker. And it’s adaptive: as hardware gets faster, we just bump the factor up. Same code, more resistance.

Register vs login flow

Register
password: hunter2
↓ generate salt + slow hash
STORE $2b$12$salt.hash
plaintext thrown away
Login
attempt: hunter2
↓ hash with SAME stored salt
compare to stored hash
✓ match → logged in
✗ mismatch → rejected

The trick that surprises people: the salt lives inside the bcrypt hash string, so at login we don’t fetch it separately — compare reads it out for us and re-hashes the attempt with it.

const bcrypt = require("bcrypt");

// Register: cost factor 12, salt is auto-generated and baked into the result
const hash = await bcrypt.hash(plainPassword, 12);
// store `hash` in the DB, e.g. "$2b$12$Np3q...<salt+digest>"

// Login: bcrypt pulls the salt out of the stored hash and re-hashes the attempt
const ok = await bcrypt.compare(loginAttempt, hash);
if (!ok) throw new Error("invalid credentials"); // constant-time compare, no leak

Notice we never wrote a salt column ourselves and never compared strings with ===compare does a constant-time check so an attacker can’t time our response to guess characters.

Interview soundbite

Never store plaintext. Hashing is one-way (right for passwords); encryption is reversible (wrong here). Fast hashes like MD5/SHA are guessable at billions/sec and vulnerable to rainbow tables. Add a per-user salt to kill rainbow tables and an optional pepper (secret, kept out of the DB) for defence in depth. Use a slow adaptive hash — bcrypt/scrypt/argon2 — with a work factor tuned so each hash costs ~250ms, and bump it up as hardware improves.