Storing passwords is one of those tasks that looks trivial until you get it wrong. You can't just dump them in a column, and you shouldn't reach for SHA-256 either. What you want is a slow, salted hash designed specifically for passwords. bcrypt is one of the oldest and most battle-tested options, and it's still a perfectly good default in 2024.

What bcrypt actually is

bcrypt is a password hashing function built on the Blowfish cipher. It was designed in 1999 specifically to be slow and to get slower as hardware improves. That sounds backwards, but it's the whole point. The goal isn't speed — it's to make brute-force attacks expensive.

Two properties make bcrypt good at its job:

  • Built-in salting. Every hash includes a random salt, so two users with the same password get different hashes. No rainbow tables.
  • A tunable cost factor. You pick a "work factor" that controls how many rounds of key expansion run. Bump it up as CPUs get faster.

A bcrypt hash looks like this:

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

It's self-describing. $2b$ is the algorithm version, 12 is the cost factor, the next 22 characters are the salt, and the rest is the hash itself. Because everything you need to verify a password is baked into the string, you store just this one value — no separate salt column.

When you'd use it

Reach for bcrypt whenever you're storing user passwords in a database. That's the main use case. If you're signing tokens, generating API keys, or hashing file contents for integrity checks, bcrypt is the wrong tool — use HMAC, a CSPRNG, or SHA-256 respectively. bcrypt is deliberately slow, which is a feature for passwords and a bug for everything else.

Modern alternatives exist. Argon2 won the Password Hashing Competition and is arguably the better choice for new systems because it resists GPU attacks more effectively. scrypt is another solid option. But bcrypt has one big advantage: it's everywhere. Every language has a mature, well-audited library. If your platform doesn't have a good Argon2 binding, bcrypt is a completely defensible fallback.

A worked example

Here's hashing and verifying in Node.js with the bcrypt package:

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

// When a user registers
async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

// When a user logs in
async function checkPassword(plain, stored) {
  return bcrypt.compare(plain, stored);
}

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

Notice that compare takes the plaintext and the full stored hash. It extracts the cost factor and salt from the hash string itself, re-runs the computation, and does a constant-time comparison. You never manage salts by hand.

If you just want to eyeball what a bcrypt hash looks like for a given input and cost — handy when debugging a stored value or confirming a library's output — our Bcrypt Hash Generator will produce one in the browser without you writing a line of code.

Picking a cost factor

The cost factor is exponential: each increment doubles the work. A cost of 12 is a reasonable starting point for a web app on modern server hardware — it lands somewhere around 200–300ms per hash, which is slow enough to deter attackers but fast enough that login doesn't feel sluggish. Benchmark on your actual production hardware and aim for roughly 100–250ms. If hashing takes 10ms, raise the cost.

Common gotchas

The 72-byte limit

bcrypt silently truncates input at 72 bytes. If your users have very long passwords (or passphrases in a multi-byte encoding), everything past byte 72 is ignored. This has bitten real companies. A common mitigation is to pre-hash with SHA-256 and base64-encode before passing to bcrypt, which collapses any length into a fixed-size input:

const crypto = require('crypto');

function prehash(password) {
  return crypto.createHash('sha256')
    .update(password)
    .digest('base64');
}

const hash = await bcrypt.hash(prehash(plain), 12);

Do this consistently on both registration and login, or nothing will match.

Null byte truncation

Some older bcrypt implementations truncate at the first null byte. Pre-hashing to base64 also sidesteps this, since base64 output never contains null bytes.

Don't roll your own comparison

Always use the library's compare / verify function. It runs in constant time to avoid timing attacks. Comparing hashes with === or a plain string equality check can leak information about how many characters matched.

Upgrading cost over time

You set cost 10 three years ago and now want 12. You can't rehash a password you don't have in plaintext. The trick: when a user logs in successfully, you do have the plaintext for a moment. Check whether the stored hash's cost is below your target, and if so, rehash and update the record right there.

if (await bcrypt.compare(plain, stored)) {
  const cost = bcrypt.getRounds(stored);
  if (cost < SALT_ROUNDS) {
    const newHash = await bcrypt.hash(plain, SALT_ROUNDS);
    await db.updatePasswordHash(user.id, newHash);
  }
  // log them in
}

Don't cache or log the plaintext

Obvious, but worth repeating. Hash as early as possible in the request lifecycle, and never write the plaintext to logs, error reports, or analytics.

The short version

  • Use bcrypt (or Argon2) for passwords, nothing else.
  • Store the whole hash string; it contains the salt and cost.
  • Pick a cost around 12 and benchmark on real hardware.
  • Watch the 72-byte truncation — pre-hash long inputs.
  • Use the library's constant-time compare, never your own.
  • Rehash on login when you raise the cost factor.

Password hashing is a solved problem as long as you use the right tool correctly. bcrypt has been doing that job reliably for over two decades, and it'll keep doing it as long as you remember to bump the cost factor every few years.

Photo by FlyD on Unsplash