If you're storing user passwords, you shouldn't be storing passwords at all. You store a hash — and for most applications, bcrypt is a solid, boring, correct default. It's been around since 1999, it's battle-tested, and libraries exist for every language you're likely to use.

Let's go through what bcrypt actually does, why it's still recommended, and the mistakes that keep showing up in code reviews.

What bcrypt actually is

Bcrypt is a password-hashing function based on the Blowfish cipher. The key thing that separates it from something like SHA-256 is that it's deliberately slow. That sounds like a bug, but it's the entire point.

A fast hash is great for checking file integrity. It's terrible for passwords, because an attacker who steals your database can try billions of guesses per second on a GPU. Bcrypt has a tunable work factor (also called cost or rounds) that lets you dial in how expensive each hash is. Bump the cost, and every guess an attacker makes gets slower too.

Bcrypt also handles two things for you automatically:

  • Salting — a random value mixed into each hash so identical passwords produce different hashes. You don't manage this yourself; bcrypt generates and stores the salt inside the output string.
  • Self-describing output — the resulting hash encodes the algorithm version, the cost, the salt, and the hash together. That means verification needs nothing but the stored string and the candidate password.

Reading a bcrypt hash

A bcrypt hash looks like this:

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW

Broken down:

  • $2b$ — the algorithm version.
  • 12 — the cost factor (2^12 = 4096 iterations).
  • The next 22 characters — the salt.
  • The rest — the actual hash.

Everything the library needs to verify a password lives in that one string. No separate salt column required.

When to use it (and when not to)

Use bcrypt when you're storing credentials that users type in: account passwords, API secrets that need to be checked against user input, that sort of thing. For the vast majority of web and mobile apps, it's the right call.

Where bcrypt is not the tool:

  • Hashing files or verifying downloads — use SHA-256 there. Speed is a feature for that job.
  • Generating HMAC signatures for tokens — different problem, different tool.
  • Storing data you need to read back later — that's encryption, not hashing. Hashing is one-way by design.

Argon2 is the more modern alternative and won the Password Hashing Competition, so if you're greenfield and your platform has a mature Argon2 library, it's worth considering. But bcrypt is not obsolete. It's widely supported, well understood, and hard to misuse. "Boring and correct" beats "cutting-edge and fiddly" for security-critical code.

A worked example

Here's the whole lifecycle in Node.js using the bcrypt package. The pattern is nearly identical in every language.

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

// Registration: hash and store
async function hashPassword(plain) {
  return await bcrypt.hash(plain, SALT_ROUNDS);
}

// Login: compare candidate against stored hash
async function verifyPassword(plain, storedHash) {
  return await bcrypt.compare(plain, storedHash);
}

// Usage
const hash = await hashPassword('correct horse battery staple');
// -> $2b$12$....  store this in the users table

const ok = await verifyPassword('correct horse battery staple', hash);
// -> true

Notice you never manually create a salt, and you never compare strings with ===. bcrypt.compare handles the salt extraction and does a timing-safe comparison for you.

If you just want to eyeball what a hash looks like or test a value quickly without wiring up code, our Bcrypt Hash Generator lets you produce and inspect hashes in the browser. Handy for debugging a mismatch or checking that your cost factor is what you think it is.

Common gotchas

The 72-byte limit

Bcrypt only looks at the first 72 bytes of the input. Anything past that is silently ignored. This bites people who "pre-hash" a password by piping it through SHA-256 first and passing the hex string in — no problem there since that's under 72 chars — but it becomes a real issue if you accept very long passphrases and assume all of them matter. If you support long inputs, pre-hash with SHA-256 into a fixed-length digest before bcrypt, or move to Argon2.

Choosing the cost factor

Cost 10 was fine a decade ago. Today, 12 is a reasonable baseline for a typical web server, and you should benchmark on your hardware. Aim for roughly 100–250ms per hash. Too low and you're not slowing attackers down; too high and your login endpoint becomes a DoS vector against yourself.

Truncating or encoding weirdness

Some older bcrypt implementations mishandle null bytes or non-ASCII input. Modern libraries are fine, but be consistent about encoding (use UTF-8) and don't do surprising transformations on the password before hashing.

Never log the plaintext

Obvious, but it happens. Make sure the raw password never lands in your logs, error tracker, or an analytics payload. Hash it as early as possible and let the plaintext fall out of scope.

Rehashing on login

Because the cost is baked into the hash, you can upgrade security over time. On successful login, check whether the stored hash uses your current cost. If not, rehash the plaintext (which you have, momentarily, during login) at the new cost and update the record. This lets you raise the work factor as hardware improves without forcing password resets.

if (ok && bcrypt.getRounds(storedHash) < SALT_ROUNDS) {
  const newHash = await bcrypt.hash(plain, SALT_ROUNDS);
  await users.updateHash(userId, newHash);
}

The short version

Bcrypt gives you slow, salted, self-contained password hashes with almost no configuration. Pick a cost factor around 12, use the library's hash and compare functions instead of rolling anything yourself, watch the 72-byte limit, and rehash on login when you bump the cost. Do that and your password storage will be in better shape than a surprising number of production systems.

Photo by FlyD on Unsplash