If you're storing passwords in plaintext, stop reading and go fix that first. For everyone else, bcrypt remains one of the most sensible defaults for hashing passwords in 2024. It's been around since 1999, it's battle-tested, and it does one thing well: it makes cracking stolen password hashes expensive.

What bcrypt actually is

Bcrypt is an adaptive password hashing function based on the Blowfish cipher. Unlike a general-purpose hash like SHA-256, it's designed to be slow — and that slowness is a feature, not a bug. When an attacker gets hold of your database, the only practical way to recover passwords is to guess candidates and hash each one. If a single hash takes 200ms instead of a few microseconds, brute-forcing millions of guesses becomes wildly impractical.

Two things make bcrypt good at this:

  • A built-in salt. Every hash includes a random salt, so identical passwords produce different hashes. This kills rainbow-table attacks outright.
  • A tunable cost factor. You control how much work each hash takes. As hardware gets faster, you bump the cost up. No code rewrite needed.

Reading a bcrypt hash

A bcrypt hash looks like this:

$2b$12$eImiTXuWVxfM37uY4JANjQ.M2W9WQ8s6h/Sp0h1mZ8N9lPUqR0Xhq

It packs everything into one string:

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

Because the salt and cost are embedded, you don't need a separate column for them. You just store the whole string and pass it back in when verifying.

When to reach for it

Use bcrypt for user passwords and similar low-entropy secrets that humans choose. That's the sweet spot.

Don't use it for:

  • API keys and tokens — these are long random strings, so a fast hash like SHA-256 is fine and much cheaper.
  • File integrity or checksums — wrong tool entirely.
  • Encrypting data you need to read back — hashing is one-way; use encryption for that.

If you're on a newer stack and starting fresh, Argon2id is arguably the modern first choice and won the Password Hashing Competition. But bcrypt is still perfectly acceptable, widely supported, and often the path of least resistance because a mature library already exists for your language.

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;

// 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');
const ok = await verifyPassword('correct horse battery staple', hash);
console.log(ok); // true

Note there's no manual salt handling. bcrypt.hash generates one for you and embeds it, and bcrypt.compare reads it back out. Trying to salt things yourself is a classic way to introduce bugs.

If you just want to eyeball a hash or generate one for testing without wiring up a whole project, our Bcrypt Hash Generator lets you produce and verify hashes right in the browser — handy for seeding a dev database or checking that your cost factor produces the string format you expect.

Gotchas that bite people

The 72-byte limit

Bcrypt only looks at the first 72 bytes of input. Anything beyond that is silently ignored. This matters if you allow very long passphrases, and it's a real security hole if you naively pre-hash. If you want to support arbitrary-length passwords, hash with SHA-256 first and base64-encode the result before feeding it to bcrypt — but be aware of null-byte truncation issues in some implementations. Test this behaviour with your specific library.

Choosing the cost factor

Cost is a trade-off between security and login latency. A good rule: pick the highest value that keeps hashing under roughly 250ms on your production hardware. Benchmark it — don't guess.

console.time('bcrypt');
await bcrypt.hash('test', 12);
console.timeEnd('bcrypt');

Cost 12 is a reasonable modern default. Cost 10 is on the low side now. Revisit it every year or two.

Timing attacks

Always use the library's compare function, never a plain string equality check. bcrypt.compare does a constant-time comparison so an attacker can't infer the hash byte by byte from response timing.

Rehashing on login

When you raise the cost factor, existing hashes stay at the old cost. You can't rehash a stored hash without the plaintext. The clean approach: on a successful login, check whether the stored hash's cost is below your current target, and if so, rehash the freshly supplied plaintext and update the record.

async function loginAndMaybeUpgrade(plain, storedHash) {
  const ok = await bcrypt.compare(plain, storedHash);
  if (!ok) return null;

  if (bcrypt.getRounds(storedHash) < SALT_ROUNDS) {
    const upgraded = await bcrypt.hash(plain, SALT_ROUNDS);
    // save upgraded to DB
  }
  return 'ok';
}

Don't roll your own

This should go without saying, but people still do it: don't implement bcrypt yourself, and don't cobble together your own scheme with SHA plus a static salt. Use the maintained library for your platform and let it handle salt generation, encoding, and comparison.

The short version

Bcrypt gives you salted, slow, adaptive password hashing with almost no configuration beyond picking a cost factor. Store the full hash string, use the library's compare function, mind the 72-byte limit, and plan to nudge the cost up over time. Do that and you've handled password storage properly — which puts you ahead of a depressing number of production systems.

Photo by FlyD on Unsplash