ToolPal
A combination lock on a keyboard symbolizing password security

bcrypt Password Hashing: The Developer's Practical Guide

πŸ“· Pixabay / Pexels

bcrypt Password Hashing: The Developer's Practical Guide

Everything you need to know about bcrypt for password security β€” cost factors, implementation in Node.js/Python/PHP, and common mistakes to avoid.

April 7, 20269 min read

If you've ever had to store user passwords, you've probably been told to "use bcrypt." It's been the standard recommendation for over two decades, and for good reason. But understanding why it works β€” and how to use it correctly β€” is what separates a secure implementation from one that just looks secure.

This guide covers everything practical: how bcrypt actually works, what cost factor to pick, how to implement it in multiple languages, and the mistakes that quietly undermine password security.

What bcrypt Is (and Why It Exists)

bcrypt was designed by Niels Provos and David Mazières in 1999, specifically for password hashing. That context matters. It wasn't built to be fast — it was built to be slow in a controlled, tunable way.

The core insight behind bcrypt is that fast hashing is a liability when it comes to passwords. A general-purpose hash like SHA-256 can compute billions of hashes per second on modern hardware. That's great for checksums and data integrity. It's terrible for passwords, because an attacker who steals your password database can attempt billions of guesses per second with commodity GPUs.

bcrypt solves this by introducing a cost factor β€” a parameter that controls how much work the algorithm does. You decide how slow the hashing is. Hardware gets faster every year, so you can increase the cost factor over time to keep up.

How bcrypt Actually Works

When you call bcrypt.hash("mypassword", 10), several things happen under the hood:

  1. A random 16-byte salt is generated. This salt is unique to each hash operation.
  2. The password and salt are fed into the Eksblowfish key setup. This is bcrypt's core algorithm β€” a modified version of the Blowfish cipher with an expensive key expansion phase.
  3. The key setup is repeated 2^cost times. With cost factor 10, that's 1,024 iterations. With cost 12, it's 4,096 iterations. Each increment doubles the work.
  4. The resulting hash is encoded as a 60-character string that contains the algorithm version, cost factor, salt, and hash all in one.

The output looks like this:

$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

Breaking that down:

  • $2b$ β€” bcrypt version
  • 10 β€” cost factor
  • The next 22 characters β€” the base64-encoded salt
  • The remaining characters β€” the hash

Because the salt is embedded in the hash string, you don't need to store it separately. The bcrypt.compare() function extracts the salt from the stored hash and uses it to hash the input password β€” no manual salt management required.

Why Fast Hash Functions Are the Wrong Tool

Developers sometimes store passwords with MD5, SHA-1, or SHA-256. These algorithms are widely available and well-understood, so the choice seems reasonable. It isn't.

The problem isn't that these algorithms are broken for their intended purposes (though MD5 and SHA-1 have collision vulnerabilities). The problem is that they're designed to be fast, and fast is exactly wrong for password storage.

In 2012, security researcher Jeremi Gosney demonstrated cracking 90% of a leaked 6.4 million SHA-1 password hash database in under an hour. With modern hardware and tools like Hashcat, an attacker with a decent GPU can test MD5 hashes at over 10 billion attempts per second. SHA-256 isn't much better β€” around 4 billion per second.

bcrypt at cost 10 brings that down to roughly 20,000 attempts per second. That's the entire point.

Choosing the Right Cost Factor

The cost factor is the most important tuning decision you'll make with bcrypt.

Cost 10 is the widely recommended default. On a modern server, it produces a hash in roughly 100–300ms. That's imperceptibly slow for a user logging in, but means an attacker can only test a few thousand passwords per second per core.

Cost 12 roughly quadruples the computation time compared to 10. It's a reasonable choice for applications with elevated security requirements β€” banking, healthcare, anything handling sensitive financial data β€” where you can accept 400–1000ms login times.

Cost 14 and above is usually overkill for web applications. At cost 14, hashing takes several seconds, which noticeably affects user experience and server throughput under load.

Never go below 8 in production. Some old tutorials show cost 5 or 6 as examples. Those values are too fast and provide inadequate protection.

A practical rule: benchmark on your actual hardware and pick the highest cost factor that keeps login time under 250ms at your expected concurrent load. Then revisit every two to three years as hardware improves.

Implementing bcrypt in Node.js

The bcryptjs and bcrypt packages are both widely used. bcrypt is a native addon and slightly faster; bcryptjs is pure JavaScript and works anywhere without native compilation.

const bcrypt = require('bcrypt');

const COST_FACTOR = 10;

// Hashing a password
async function hashPassword(plaintext) {
  const hash = await bcrypt.hash(plaintext, COST_FACTOR);
  return hash; // Store this string in your database
}

// Verifying on login
async function verifyPassword(plaintext, storedHash) {
  const match = await bcrypt.compare(plaintext, storedHash);
  return match; // true or false
}

// Example usage
const hash = await hashPassword('hunter2');
console.log(hash);
// $2b$10$...

const valid = await verifyPassword('hunter2', hash);
console.log(valid); // true

const invalid = await verifyPassword('wrong', hash);
console.log(invalid); // false

One thing worth noting: bcrypt.hash() is async, which means it runs in a thread pool and won't block your event loop. Use the async version in production β€” the synchronous bcrypt.hashSync() will stall your server under load.

Implementing bcrypt in Python

The bcrypt library is the standard choice:

import bcrypt

COST_FACTOR = 10

def hash_password(plaintext: str) -> bytes:
    password_bytes = plaintext.encode('utf-8')
    hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt(rounds=COST_FACTOR))
    return hashed  # Store as bytes or decode to string

def verify_password(plaintext: str, stored_hash: bytes) -> bool:
    password_bytes = plaintext.encode('utf-8')
    return bcrypt.checkpw(password_bytes, stored_hash)

# Usage
hashed = hash_password("hunter2")
print(hashed)  # b'$2b$10$...'

print(verify_password("hunter2", hashed))  # True
print(verify_password("wrong", hashed))    # False

When storing in a database, you can store the hash as bytes directly (if using a binary column) or decode it to a UTF-8 string β€” the bcrypt hash is ASCII-safe, so either works.

Implementing bcrypt in PHP

PHP's built-in password_hash() function uses bcrypt by default and is the correct approach:

<?php
$options = ['cost' => 10];

// Hashing
$hash = password_hash($plaintext, PASSWORD_BCRYPT, $options);

// Verification
$valid = password_verify($plaintext, $hash);

// Check if the hash needs to be upgraded
// (e.g., you raised the cost factor)
if (password_needs_rehash($hash, PASSWORD_BCRYPT, $options)) {
    $newHash = password_hash($plaintext, PASSWORD_BCRYPT, $options);
    // Save $newHash to the database
}
?>

The password_needs_rehash() function is worth knowing about. If you increase your cost factor, you can call this on each successful login and silently re-hash with the higher cost. Users get more security without any disruption.

Implementing bcrypt in Go

The golang.org/x/crypto/bcrypt package handles this cleanly:

import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
    return string(bytes), err
}

func CheckPasswordHash(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

Common Mistakes That Undermine bcrypt

Re-hashing the password on every login verification. This is a significant performance mistake. When a user logs in, you should compare their password against the stored hash using bcrypt.compare(). Do not hash the password again and compare the two hashes β€” that will never match, because a new random salt is generated each time.

Storing the plaintext password. Obvious, but it happens. A password manager breach a few years back stored passwords in plaintext "for recovery purposes." There is no recovery purpose worth that risk.

Using MD5 or SHA for password storage. Even with a salt added manually, fast hashes give attackers far too much throughput. If you're migrating a legacy system that used MD5 hashes, handle existing users by checking the MD5 hash on login, then re-hashing with bcrypt and storing the new hash.

Not handling the 72-byte limit. bcrypt silently truncates input at 72 bytes. For most users with normal passwords, this is never a problem. But if your application explicitly allows very long passphrases, two different 80-character strings that share the same first 72 bytes will produce the same bcrypt hash. The standard mitigation is to SHA-256 hash the password first, then bcrypt the hex or binary output β€” though this technically means bcrypt doesn't see a "password" anymore.

Using a cost factor that's too low. If you're running cost 4 because it's faster, you're missing the point. The slowness is a security property, not a bug.

Testing Your bcrypt Implementation

If you want to verify that your hashes look correct or experiment with cost factors before wiring up a server, the bcrypt generator tool on ToolBox Hub lets you hash strings and verify hashes directly in the browser.

You can also cross-reference with the hash generator to compare bcrypt output against SHA-256 and see the structural difference β€” bcrypt's self-contained format versus the raw hex output of SHA functions.

For generating test passwords to hash, the password generator can produce random strings of various lengths and character sets.

When to Use Argon2 Instead

bcrypt has been the safe default for 25 years, but it's not the modern best-in-class choice. Argon2 β€” specifically Argon2id β€” won the Password Hashing Competition in 2015 and addresses bcrypt's main weakness.

bcrypt is CPU-bound: an attacker with GPUs can parallelize cracking relatively cheaply because the algorithm doesn't require much memory. Argon2 is memory-hard: it requires a configurable amount of RAM per hash operation. GPUs have far less memory per compute core than a CPU does, so memory-hard algorithms level the playing field significantly.

OWASP's current recommendation puts Argon2id first, with bcrypt as a solid fallback. If you're starting a new application today and your language and framework have good Argon2 support, it's worth considering. But if you're already running bcrypt at cost 10 or 12, you're not in a bad place β€” bcrypt is not broken, and migrating password hashing systems carries its own risks.

Summary

  • bcrypt's strength comes from being deliberately slow, not from cryptographic complexity
  • Always use bcrypt.compare() (or your language's equivalent) for verification β€” never hash and compare
  • Cost factor 10 is the right default for most applications; revisit every few years
  • bcrypt handles salting automatically β€” you don't need to manage salts
  • Be aware of the 72-byte truncation limit for unusually long passwords
  • For new projects, Argon2id is worth considering; for existing bcrypt systems, stay the course

Password storage is one of the few areas where the secure option β€” bcrypt with a reasonable cost factor β€” is also simple to implement correctly. Most of the vulnerabilities in this space come from ignoring the advice, not from flaws in the algorithm itself.

Frequently Asked Questions

Share this article

XLinkedIn

Related Posts