Stop Storing Passwords Wrong: A Plain-English Guide to Bcrypt

Let me guess. You know you're not supposed to store passwords as plain text. You've heard the phrase "hash your passwords" enough times that it's basically white noise at this point. You might even be hashing them right now — with SHA-256 or MD5 — and feeling pretty good about it.

Here's the uncomfortable truth: if you're using SHA-256 for passwords, you're doing it wrong. Not "technically suboptimal" wrong. Wrong in a way that will absolutely get your users' passwords cracked within hours if your database leaks.

I'm going to explain why, and I'm going to do it without assuming you remember anything from your cryptography course. Let's start from zero.

What Even Is a Hash?

A hash function takes any input — "hunter2", "correcthorsebatterystaple", an entire novel — and spits out a fixed-length string of gibberish. SHA-256 always produces 64 hex characters. Always. No matter what goes in.

The magic properties: you can't reverse it (there's no "un-hash" button), and two different inputs almost never produce the same output. So the idea was simple: store the hash, not the password. When a user logs in, hash what they typed and compare it to what you stored. If the hashes match, the password matched. Brilliant.

And then reality showed up.

The Rainbow Table Problem (Or: Why "Just Hash It" Isn't Enough)

Here's something that took me embarrassingly long to fully appreciate. SHA-256 is a deterministic function. Put in "password123" and you always get ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f. Every single time. On every machine. In every language.

Attackers figured this out around the same time as everyone else. They pre-computed the SHA-256 hash of every common password, every dictionary word, every "clever" substitution like p@ssw0rd. These massive lookup tables are called rainbow tables, and they're freely available online. Billions of pre-computed entries.

Your database leaks. The attacker downloads it, runs it against the rainbow table, and within seconds they have the plaintext for every account using a common password. No brute force. No GPU cracking. Just a lookup.

That's problem one. Problem two is even scarier.

SHA-256 Is Fast. Like, Terrifyingly Fast.

SHA-256 was designed for speed. It's used in TLS, file integrity checks, blockchain mining — all situations where you want to hash data as quickly as possible. On modern hardware, a single GPU can compute billions of SHA-256 hashes per second.

For file integrity: great. For password storage: catastrophic.

Even if you avoid rainbow tables by using a technique called salting (more on that in a moment), an attacker with a cracked database and a gaming GPU can try billions of password guesses per second. An 8-character password using lowercase letters and numbers has about 2.8 trillion combinations. Sounds like a lot. At 8 billion attempts per second, that's under six minutes.

This is not theoretical. This is Tuesday for serious attackers.

Enter Bcrypt: Designed to Be Slow on Purpose

In 1999, two researchers named Niels Provos and David Mazières published bcrypt specifically as a response to this problem. The core insight was almost aggressively simple: make the hash function slow.

Not "slow" like "takes half a second on a Raspberry Pi." Slow in a configurable, intentional way that you control — and that stays meaningfully slow even as hardware gets faster.

Bcrypt does this with something called a cost factor (sometimes called "work factor" or "rounds"). It's a number, typically between 10 and 14, that determines how computationally expensive the hashing operation is. Each increment doubles the work. Cost 10 might take 100ms. Cost 12 takes 400ms. Cost 14 takes around 1.6 seconds.

On your server, 100–400ms per login is totally acceptable. Users don't notice. But for an attacker trying to brute-force your leaked database? Suddenly instead of 8 billion guesses per second, they're down to a few thousand. That 8-character password that took 6 minutes to crack with SHA-256 now takes decades.

Salting: How Bcrypt Kills Rainbow Tables

Bcrypt automatically handles salting, which is why you should let it handle the whole thing rather than rolling your own.

A salt is random data that gets mixed into the password before hashing. Here's the crucial part: every password gets its own unique salt, generated fresh every time. Bcrypt generates a 128-bit random salt automatically when you hash a password.

Why does this matter? Even if two users have the exact same password ("ilovecats99"), their bcrypt hashes will be completely different because their salts are different. Rainbow tables become useless overnight. The attacker can't pre-compute anything — they'd have to generate a custom rainbow table for each individual user's salt, which defeats the entire point of rainbow tables.

The salt is stored directly inside the bcrypt hash output, so you don't need a separate column in your database. The full output looks like this:

$2b$12$EXRkfkdmXn2gzds2SSitu.MW9.TNq4N7p.v7CRmYZPLM/BaKbHPm6

That string encodes everything: the algorithm version (2b), the cost factor (12), the salt (first 22 characters after the last $), and the hash. One field. Self-contained.

Actually Using Bcrypt (It's Three Lines)

The good news: you don't implement bcrypt yourself. Every major language has a battle-tested library. Here's how it looks:

Node.js (bcrypt):

const bcrypt = require('bcrypt');

// Hashing a password (e.g., during registration)
const hash = await bcrypt.hash(plaintextPassword, 12); // 12 = cost factor
await db.users.save({ email, passwordHash: hash });

// Verifying a password (e.g., during login)
const isValid = await bcrypt.compare(inputPassword, storedHash);
if (!isValid) return res.status(401).send('Wrong password');

Python (bcrypt):

import bcrypt

# Hashing
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))

# Verifying
bcrypt.checkpw(input_password.encode(), stored_hash)

PHP:

// Hashing
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verifying
if (password_verify($input, $storedHash)) { /* logged in */ }

Notice you never deal with the salt yourself. The library handles it. You store the output string. You compare using the library's verify function. That's genuinely it.

Picking Your Cost Factor

Here's a practical heuristic: run a benchmark on your actual production hardware and pick the cost factor that makes a single hash take somewhere between 100ms and 300ms. That's slow enough to frustrate attackers but fast enough that your login endpoint doesn't become a bottleneck.

Most production apps land between cost 10 and 12. If you're on a beefy server with SSD-backed everything, try 12. Shared hosting on a weak VPS? Start at 10 and test.

Here's the nice part: bcrypt stores the cost factor in the hash string. So if you later increase your cost factor (because hardware got faster and 10 is now too cheap), you can transparently re-hash passwords on each login without a forced reset. When a user logs in and you verify their old hash, you detect the lower cost factor, re-hash with the new setting, and save the new hash. Zero disruption.

What About Argon2?

Fair question. Argon2 won the Password Hashing Competition in 2015 and is technically the modern recommendation. It's memory-hard (uses lots of RAM during hashing, which makes GPU cracking even more expensive), and if you're starting a brand new project today, argon2id is a reasonable choice.

But if you're working on an existing system using bcrypt? Don't migrate just to migrate. Bcrypt is still genuinely secure. The threat model for most web apps doesn't require Argon2's additional memory-hardness. Use Argon2 on new projects; don't create churn on existing ones that are already doing bcrypt correctly.

The One Thing to Remember

If you walk away with only one thing, let it be this: password hashing and data hashing are completely different jobs. Fast hashes (SHA-256, MD5, SHA-1) are the right tool for checksums, data integrity, digital signatures, and a dozen other things. They are categorically the wrong tool for passwords. Never mix them up.

Bcrypt (or Argon2id) exists specifically for passwords. It's slow by design. It salts automatically. It's tunable. It's been audited for 25 years. The libraries are already in your package manager.

You're now several miles ahead of developers who are still MD5-ing passwords in 2026. Go update that legacy codebase.