If you're storing user passwords, you don't store passwords. You store hashes. And for that job, bcrypt is still one of the best defaults you can reach for in 2024. It's been around since 1999, it's boring in the best way, and it's available in pretty much every language you'll touch.

Let's go through what bcrypt actually does, why it beats a plain SHA-256, the mistakes I keep seeing in code reviews, and a small example you can run.

What bcrypt actually is

Bcrypt is a password-hashing function built on the Blowfish cipher. Two things make it good for passwords specifically:

  • It's deliberately slow. A fast hash like SHA-256 is great for checksums but terrible for passwords, because an attacker with a leaked database can try billions of guesses per second on a GPU. Bcrypt is designed to be expensive to compute.
  • The cost is tunable. You pick a "work factor" (the cost). As hardware gets faster, you bump the number up. Same algorithm, more resistance.

Bcrypt also handles salting for you. Every hash gets a unique random salt baked right into the output string, so two users with the password hunter2 end up with completely different hashes. No separate salt column to manage.

Anatomy of a bcrypt hash

A bcrypt hash is a single self-contained string. It looks like this:

$2b$12$eImiTXuWVxfM37uY4JANjQ.dyfXbLYm5NqOo6VJPZ.LqGkkQ8YQaK
 |  |  |------------------------|-----------------------------|
 |  |         salt (22 chars)          hash
 |  cost factor (12)
 version

Everything the verify step needs is in there: the algorithm version, the cost, the salt, and the hash. That's why you never store the salt separately — it's part of the string.

When to use it (and when not to)

Use bcrypt when you're hashing passwords or anything a human types and re-enters to log in. That's its whole reason for existing.

Don't use it for:

  • API keys or tokens — those are high-entropy random strings, not human passwords. A fast hash like SHA-256 is fine there.
  • File integrity or checksums — use SHA-256/512.
  • Encryption — hashing is one-way. If you need to get the value back, you want encryption, not a hash.

One honest caveat: bcrypt is a solid choice, but Argon2id is the current "best in class" if your platform supports it well, and scrypt is another good option. If you're on a stack where Argon2 bindings are painful or unmaintained, bcrypt is a perfectly defensible default and nobody will fault you for it.

A worked example

Here's the typical flow in Node.js using the bcrypt package. Signup hashes the password; login compares.

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

// On signup
async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

// On login
async function verifyPassword(plain, storedHash) {
  return bcrypt.compare(plain, storedHash);
}

// Usage
const hash = await hashPassword('correct horse battery staple');
console.log(hash);
// $2b$12$....

const ok = await verifyPassword('correct horse battery staple', hash);
console.log(ok); // true

Notice you never re-generate a salt to verify. bcrypt.compare reads the salt and cost straight out of the stored hash, recomputes, and does a constant-time comparison. That's the whole point.

If you want to eyeball a hash quickly — say you're debugging a login that keeps failing and you want to see what a valid bcrypt string for a given password looks like — you can generate one in the browser with the Bcrypt Hash Generator and compare formats. Handy for seeding a test user or sanity-checking cost factors without spinning up a script.

Common gotchas

1. The 72-byte limit

Bcrypt only looks at the first 72 bytes of the input. Anything past that is silently ignored. For normal passwords this never matters. But if you "pre-hash" with something that produces long output, or you let users paste passphrases that are effectively identical in their first 72 bytes, you can get surprises. A common pattern is to SHA-256 the password first (giving a fixed-length input), then bcrypt that — but do this carefully, because base64-encoding a raw SHA-256 has null-byte and length quirks. If you don't have a specific reason, just bcrypt the raw password.

2. Picking the cost factor

The default in many libraries is 10. That was reasonable years ago; 12 is a sensible modern baseline. The right number is the one that takes roughly 200–300ms on your production hardware. Benchmark it — don't guess. Too low and you're not slowing attackers; too high and your login endpoint becomes a self-inflicted DoS under load.

3. Timing attacks on comparison

Never compare hashes with == or string equality. Use the library's compare function, which runs in constant time. Rolling your own comparison leaks information through timing.

4. Null bytes

Some older bcrypt implementations truncate at the first null byte. If passwords might contain them (rare, but possible with certain input encodings), verify how your library behaves.

5. Upgrading cost over time

When a user logs in successfully, that's your chance to re-hash. Check whether the stored hash's cost is below your current target; if so, re-hash the plaintext they just gave you and update the record. Most libraries expose a helper for reading the cost from a hash. This lets you migrate the whole user base to a stronger cost gradually, without forcing password resets.

async function verifyAndUpgrade(plain, storedHash, save) {
  const ok = await bcrypt.compare(plain, storedHash);
  if (!ok) return false;

  const cost = bcrypt.getRounds(storedHash);
  if (cost < SALT_ROUNDS) {
    const newHash = await bcrypt.hash(plain, SALT_ROUNDS);
    await save(newHash);
  }
  return true;
}

The short version

Store hashes, never plaintext. Use bcrypt (or Argon2id if it's well supported on your stack). Let the library handle salting. Pick a cost factor that takes a couple hundred milliseconds. Use constant-time comparison. Re-hash on login to bump the cost over time. Do those six things and you've eliminated the vast majority of password-storage mistakes that end up in breach post-mortems.

Bcrypt isn't exciting, and that's exactly why it's still a good answer.

Photo by FlyD on Unsplash