What Your Login Actually Does to Your Password (hash hash baby).
on security, authentication, bcrypt, nodejs, and typescript
A few years ago an interviewer asked me a question I was sure I knew the answer to: “How do you store user passwords?”
I said something like “you hash them… with a salt?” — and then, when he asked why the salt mattered, I stuttered through an answer I didn’t trust myself. I passed the interview. The answer didn’t. It bothered me the whole way home, because I couldn’t tell whether I’d been mostly right or confidently wrong.
It turned out I was carrying a broken mental model: I thought hashing was
roughly a kind of encryption — that somewhere in the database there was a
scrambled version of my password that the server could unscramble when it
needed to check it. That model is wrong in a way that matters, and once I fixed
it, everything downstream — salts, cost factors, that weird $2b$12$... string
— stopped being trivia and started being obvious.
This is the answer I wish I’d given. We’ll follow one password all the way through: from the signup form, into the database, and back around at login.
Hashing is not encryption
The distinction I was missing:
| Property | Hashing | Encryption |
|---|---|---|
| Direction | One-way — irreversible | Two-way — reversible with a key |
| Key required | No | Yes |
| Use case | Password storage, integrity checks | Data you need to read back |
A hash function takes input of any size and produces a fixed-size digest. It’s deterministic (same input, same output), fast, and one-way — there is no “unhash”. A tiny change in input produces a completely different output.
Encryption is the opposite bargain: it’s designed to be reversed. Anyone holding the key can get the original back.
That difference is the entire point. Your server never needs to read your password again — it only ever needs to check whether a newly submitted one matches. Matching doesn’t require reversal. So the right tool is the irreversible one.
This is also why it’s a red flag when a company says it “encrypts” your passwords. If that’s literally true, they hold a key that can decrypt and read your password — which means an attacker who gets the key can too, and so can an employee. Passwords should be hashed. A company that hashes them couldn’t tell you your password if it wanted to. That’s the feature.
Attempt one: just hash it
So, registration. A user signs up with hunter2. The naive version:
store: hash("hunter2") → 2ab96390c7dbe3439de74d0c9b0b1767
Irreversible — so we’re done, right? Two problems.
First, hashing is deterministic, so every user with the same password has the same hash. Dump the table and identical rows light up. Crack one, you’ve cracked them all — and you’ve also learned which accounts share passwords.
Second, rainbow tables: precomputed lookup tables mapping common passwords
to their hashes. An attacker doesn’t need to reverse your hash; they just look
it up. hash("hunter2") is the same everywhere, so someone already computed it
years ago.
The weakness in both cases is the same: the hash depends on nothing but the password.
The salt
The fix is to mix in a random string, generated per user, before hashing:
hash(salt + "hunter2") → stored hash
Now two users with hunter2 produce completely different hashes, and no
precomputed table on earth includes your salt. Every user must be brute-forced
individually, from scratch.
Here’s the part that broke my intuition at first: the salt is stored in plaintext, right next to the hash. That feels wrong until you see what the salt’s job actually is. It isn’t a secret — it’s a uniqueness device. Its only purpose is to make identical passwords hash differently and to make precomputed tables useless. An attacker who reads the salt still has to brute-force that one user the slow way.
Making “the slow way” actually slow
Which raises the question: how slow is the slow way? A general-purpose hash like SHA-256 was built to be fast — a GPU rig can push billions of guesses per second. Per-user brute force against a fast hash is an evening’s work, not a deterrent.
So password hashing algorithms are deliberately slow, by a tunable amount
called the cost factor. bcrypt with a cost of 12 runs 2¹² rounds of
computation, and the asymmetry is the whole trick:
- A legitimate login pays the price once — ~300ms, imperceptible.
- A brute force pays it per guess — 300ms × billions of attempts is centuries.
The cost factor is a knob, not a constant: as hardware gets faster, you turn it up.
bcrypt in practice
In a TypeScript/Node service, the whole scheme is two calls:
// Registration — salt is generated internally and embedded in the output
const hashPassword = async (password: string) => {
return await bcrypt.hash(password, 12);
};
// Login — bcrypt extracts the salt, re-hashes, compares
const isValid = await bcrypt.compare(submittedPassword, storedHash);
Notice what’s missing: no salt generation, no salt column, no salt anywhere in my code. That’s because of what bcrypt actually stores. Here’s a real output string, taken apart:
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj4J/HS.iVM2
^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| | salt (22 chars) the actual hash
| cost factor
bcrypt version
The string is self-contained: algorithm version, cost factor, salt, and hash, all in one column. The users table needs exactly one field:
| id | username | password_hash |
| -- | -------- | -------------------------------------- |
| 1 | alice | $2b$12$LQv3c1yqBWVHxkd0LHAkCO...iVM2 |
| 2 | bob | $2b$12$XKp9m2nQrBWVHxkd0LHBkD...pQN4 |
And now bcrypt.compare stops being magic. At login it:
- Reads the salt and cost factor out of the stored string,
- re-hashes the submitted password with that same salt and cost,
- compares the result to the stored hash. Match →
true.
The password is never decrypted, because it was never encrypted. It’s re-derived and compared. One consequence worth knowing: never truncate that column — shorten the string and you’ve amputated the salt, and every login for that user fails forever.
For the curious: the pepper. Some systems add a second ingredient — a single secret string applied to all passwords (
hash(pepper + salt + password)), stored in server config rather than the database. The threat it answers is a database-only breach: an attacker who dumps your tables but never touches your server config is holding hashes they can’t even begin to brute-force correctly.
For the curious: why not SHA-256 with a salt? Salting fixes rainbow tables, but SHA-256 is still fast, and fast is the enemy. Password hashing needs the cost knob. MD5 and SHA-256 aren’t “weaker” choices for passwords — they’re the wrong category of tool.
Where bcrypt sits today
Honesty requires a footnote to everything above: bcrypt is a solid, widely deployed choice — it’s what I’ve run in production — but it is not the current frontier. bcrypt’s slowness is purely computational, and GPUs are very good at computation. argon2id, the current best-practice recommendation, is also memory-hard: each guess demands significant RAM, which is exactly the resource GPUs don’t have per core. Same idea as the cost factor, aimed at the attacker’s actual hardware.
If you’re maintaining a bcrypt system, this is not a fire alarm. If you’re choosing an algorithm for a new project today, look at argon2id first.
The answer I’d give now
If I could rerun that interview:
Passwords are hashed, not encrypted — nobody, including us, should be able to get the original back, and matching at login doesn’t require it. Each hash is salted with a per-user random value so identical passwords don’t produce identical hashes and precomputed tables are useless. The algorithm is deliberately slow, with a tunable cost, so a login pays milliseconds once while a brute force pays it billions of times. bcrypt packages all of that — version, cost, salt, hash — into one self-contained string, which is why
compare()needs no separate salt column.
Four sentences. The reason they were hard to produce on the spot is that each one quietly depends on knowing why hashing and encryption both exist — they aren’t rival tools for the same job, they’re different tools for opposite jobs. Encryption is for data you need back. Passwords are the canonical data you must never need back.
That’s the model I was missing. Now you have it too.