ToolPal
A padlock resting on a laptop keyboard, symbolizing digital security and password protection.

Bcrypt Password Hashing: Why It Matters and How to Use It

πŸ“· Pixabay / Pexels

Bcrypt Password Hashing: Why It Matters and How to Use It

Passwords stored as plain SHA-256 hashes can be cracked in minutes. Bcrypt is designed to be slow -- and that is exactly the point. Learn how bcrypt works, how to choose a cost factor, and how to implement it in Node.js, Python, and PHP.

April 7, 202614 min read

The Password Storage Problem

Imagine you are building a login system. A user creates an account with the password hunter2. You need to store something in your database so that when they log in next week, you can verify they typed the right thing.

The naive solution is to store hunter2 directly. This is catastrophic. Any database breach, any SQL injection, any backup left on a misconfigured S3 bucket -- the attacker now has every user's actual password.

The next step is to hash it. SHA-256("hunter2") gives you a fixed-length string that you cannot reverse. Store that instead. Better, right?

Better -- but not good enough.

The problem is that SHA-256 is designed to be fast. Modern GPUs can compute billions of SHA-256 hashes per second. An attacker with a database of SHA-256 hashed passwords and a good GPU can crack a significant portion of common passwords in hours, sometimes minutes, using precomputed tables (rainbow tables) or dictionary attacks.

Bcrypt was designed specifically to solve this. It is intentionally slow, and the speed is configurable.

You can experiment with bcrypt hashes directly using our Bcrypt Hash Generator -- no setup required.

What Bcrypt Actually Is

Bcrypt is a password hashing function designed by Niels Provos and David Mazieres in 1999, based on the Blowfish cipher. Unlike SHA-256 or MD5 (which are general-purpose cryptographic hash functions), bcrypt was built from the ground up specifically for password storage.

Three properties make it well-suited for this:

1. It Is Slow by Design

Bcrypt includes a configurable cost factor (also called work factor or salt rounds). The function performs 2^cost iterations internally. Increasing the cost factor by 1 doubles the computation time. This means you can tune the speed to match your hardware -- and as hardware gets faster, you can increase the cost factor to stay ahead.

A SHA-256 hash takes microseconds. A bcrypt hash at cost 12 takes around 200-300 milliseconds. That difference sounds small, but it changes the attacker's economics dramatically.

2. It Automatically Handles Salting

A salt is a random value added to the password before hashing. Salting ensures that two users with the same password get different hashes, and it defeats precomputed rainbow table attacks.

Bcrypt generates a cryptographically random 128-bit salt automatically and embeds it in the output hash. You do not need to manage salts yourself.

3. The Hash Is Self-Contained

The output of bcrypt contains the algorithm version, the cost factor, the salt, and the hash -- all in one string. This means you only need to store a single string per user, and you can verify a password without separately retrieving the salt.

A typical bcrypt hash looks like this:

$2b$12$LJ3m6gEwO/fSFqCVXWLwOeR/dYtTVkRDCwoGLBE0Fg6voFEOB5viy

Breaking this down:

$2b$     -- algorithm version (2b is the current standard)
12$      -- cost factor (2^12 = 4,096 iterations of the key schedule)
LJ3m6gEwO/fSFqCVXWLwOe  -- 22-character base64-encoded salt (128 bits)
R/dYtTVkRDCwoGLBE0Fg6voFEOB5viy  -- 31-character base64-encoded hash

Hashing vs. Encryption: The Key Distinction

This distinction matters enough to state clearly.

Hashing is a one-way function. You put a password in and get a hash out. There is no key, no reverse operation. The only way to check if a password matches a hash is to hash the candidate password and compare the result.

Encryption is a two-way function. You encrypt data with a key and can decrypt it back to the original with the same (or a related) key.

Passwords should always be hashed, not encrypted. If you encrypt passwords, your system contains a decryption key somewhere, and whoever gets that key has all your users' passwords. With hashing, a database breach exposes hashes, not passwords -- and with bcrypt, those hashes are slow to crack.

The exception: if your application has a legitimate reason to retrieve the original password (extremely rare -- usually legacy integrations), you might encrypt rather than hash. But in virtually every standard authentication scenario, hashing is correct.

The Cost Factor: Choosing the Right Slowness

The cost factor directly controls how much computation bcrypt does. Each increment doubles the work.

Cost FactorIterationsApproximate Time (typical server)
101,024~65ms
112,048~130ms
124,096~250ms
138,192~500ms
1416,384~1,000ms

The times above vary significantly by hardware. Run benchmarks on your actual production hardware before deciding.

OWASP's current recommendation is a cost factor of 10 as a minimum, targeting a hash time of 100ms or more. Most practitioners use 12 as a reasonable default today.

The tradeoff:

  • Too low (8 or below): Hashes are fast enough that an attacker with a decent GPU can make progress quickly against a leaked database.
  • Too high (15+): Legitimate login requests take over a second each, which is noticeable to users and creates denial-of-service potential if an attacker hammers your login endpoint.
  • Goldilocks zone (~12): ~250ms per hash is slow enough to meaningfully hinder attackers, fast enough that users will not notice.

As hardware improves, passwords hashed at cost 12 years ago become somewhat easier to crack on current hardware. Periodically re-hashing active users' passwords at a higher cost factor (on next login, when you have the plaintext password available for verification) is good practice.

Implementing Bcrypt: Code Examples

Node.js

The bcrypt package (and its pure-JS alternative bcryptjs) are the most common choices.

import bcrypt from 'bcrypt';

const COST_FACTOR = 12;

// Hashing a password
async function hashPassword(plaintext) {
  const hash = await bcrypt.hash(plaintext, COST_FACTOR);
  return hash;
  // "$2b$12$..." -- store this string in your database
}

// Verifying a password at 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$12$...

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

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

One thing to note: bcrypt.compare() uses a timing-safe comparison internally. This prevents timing attacks where an attacker infers information by measuring how long the comparison takes.

Express.js Registration and Login Route

import express from 'express';
import bcrypt from 'bcrypt';
import { db } from './database.js';

const router = express.Router();
const COST_FACTOR = 12;

// Registration
router.post('/register', async (req, res) => {
  const { email, password } = req.body;

  // Basic validation -- in production, add more thorough checks
  if (!email || !password || password.length < 8) {
    return res.status(400).json({ error: 'Invalid input' });
  }

  try {
    const hash = await bcrypt.hash(password, COST_FACTOR);

    await db.query(
      'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
      [email, hash]
    );

    res.status(201).json({ message: 'Account created' });
  } catch (error) {
    if (error.code === '23505') { // Unique constraint violation
      return res.status(409).json({ error: 'Email already registered' });
    }
    res.status(500).json({ error: 'Registration failed' });
  }
});

// Login
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.query(
    'SELECT id, password_hash FROM users WHERE email = $1',
    [email]
  );

  // Always hash even if user not found, to prevent timing-based user enumeration
  const hash = user.rows[0]?.password_hash ?? '$2b$12$invalidhashfortimingprotection';
  const match = await bcrypt.compare(password, hash);

  if (!user.rows[0] || !match) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Issue session / JWT here
  res.json({ message: 'Logged in', userId: user.rows[0].id });
});

Python (with bcrypt package)

import bcrypt

COST_FACTOR = 12

def hash_password(plaintext: str) -> str:
    """Hash a password and return the bcrypt hash string."""
    password_bytes = plaintext.encode('utf-8')
    salt = bcrypt.gensalt(rounds=COST_FACTOR)
    hashed = bcrypt.hashpw(password_bytes, salt)
    return hashed.decode('utf-8')

def verify_password(plaintext: str, stored_hash: str) -> bool:
    """Verify a plaintext password against a stored bcrypt hash."""
    password_bytes = plaintext.encode('utf-8')
    hash_bytes = stored_hash.encode('utf-8')
    return bcrypt.checkpw(password_bytes, hash_bytes)

# Usage
hash_value = hash_password('hunter2')
print(hash_value)  # $2b$12$...

print(verify_password('hunter2', hash_value))       # True
print(verify_password('wrongpassword', hash_value)) # False

Python with FastAPI

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import bcrypt

app = FastAPI()

class RegisterRequest(BaseModel):
    email: str
    password: str

class LoginRequest(BaseModel):
    email: str
    password: str

@app.post("/register")
async def register(request: RegisterRequest):
    if len(request.password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")

    password_hash = bcrypt.hashpw(
        request.password.encode('utf-8'),
        bcrypt.gensalt(rounds=12)
    ).decode('utf-8')

    # Store email and password_hash in your database
    return {"message": "Account created"}

@app.post("/login")
async def login(request: LoginRequest):
    # Retrieve user from database
    user = get_user_by_email(request.email)  # your DB lookup

    placeholder_hash = b'$2b$12$invalidhashforunknownusers000000000'
    stored_hash = user['password_hash'].encode('utf-8') if user else placeholder_hash

    is_valid = bcrypt.checkpw(request.password.encode('utf-8'), stored_hash)

    if not user or not is_valid:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    return {"message": "Logged in"}

PHP

<?php

// Hash a password
function hashPassword(string $plaintext): string {
    return password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);
}

// Verify a password
function verifyPassword(string $plaintext, string $storedHash): bool {
    return password_verify($plaintext, $storedHash);
}

// Check if a hash needs to be rehashed (e.g., if you increase cost factor)
function needsRehash(string $storedHash): bool {
    return password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => 12]);
}

// Usage
$hash = hashPassword('hunter2');
echo $hash; // $2y$12$...

var_dump(verifyPassword('hunter2', $hash));       // bool(true)
var_dump(verifyPassword('wrongpassword', $hash)); // bool(false)

// On login: rehash if needed
if (needsRehash($storedHash)) {
    $newHash = hashPassword($plaintextPassword);
    // Update the hash in your database
}
?>

PHP's built-in password_hash() with PASSWORD_BCRYPT handles salt generation automatically. Note that PHP uses $2y$ as the version prefix instead of $2b$ -- both are functionally equivalent and cross-compatible with other bcrypt implementations.

Common Mistakes and How to Avoid Them

Storing Passwords in Plain Text or with Reversible Encoding

Base64 is encoding, not hashing. base64("hunter2") is aHVudGVyMg==. Any attacker who sees this can decode it in one line. Similarly, any reversible encryption scheme requires you to store a key, and that key becomes the single point of failure.

Using Fast Hash Functions for Passwords

SHA-256, SHA-512, MD5, SHA-1 -- all of these are designed to be fast. Fast is the wrong property for password hashing. They are perfectly appropriate for checksums, digital signatures, and data integrity. They are wrong for passwords.

Not Salting (with legacy or custom implementations)

If you are not using a proper library and rolling your own, you must generate a unique random salt per password and store it with the hash. Unsalted hashes are vulnerable to rainbow table attacks -- precomputed tables of hash values for common passwords that let an attacker reverse millions of hashes instantly.

Bcrypt handles this for you automatically. If you use any bcrypt library correctly, salting is not something you need to think about separately.

The 72-Byte Truncation Issue

The original bcrypt specification only processes the first 72 bytes of input. Some libraries silently truncate input longer than 72 bytes.

In practice, most real user passwords are well under 72 bytes. But if you advertise support for long passphrases or use bcrypt to hash API keys or other long strings, you can hit this. The standard mitigation is to SHA-256 hash the input first, then bcrypt the hash:

import crypto from 'crypto';
import bcrypt from 'bcrypt';

async function hashLongPassword(plaintext) {
  // SHA-256 is 32 bytes -- safely within bcrypt's 72-byte limit
  const prehashed = crypto
    .createHash('sha256')
    .update(plaintext)
    .digest('base64'); // base64 for printable characters

  return bcrypt.hash(prehashed, 12);
}

Some argue this weakens bcrypt slightly by reducing entropy if the input was already low-entropy -- but for user passwords (which are inherently low-entropy compared to random keys), the practical difference is negligible.

Not Using Timing-Safe Comparison

If you ever compare hashes manually (instead of using the library's compare or verify function), use a constant-time comparison function. String comparison in most languages short-circuits on the first difference, which leaks information about how many characters matched. Bcrypt's built-in comparison functions handle this correctly.

// Wrong -- timing leak
if (userHash === storedHash) { ... }

// Right -- use the library's compare function
if (await bcrypt.compare(plaintext, storedHash)) { ... }

Setting the Cost Factor Too Low for Your Threat Model

Cost 10 is the floor, not the target. If your application stores anything of significant value -- financial data, health records, payment methods -- use a higher cost factor and benchmark it on your actual hardware.

Upgrading Existing Password Hashes

If your application already has passwords stored as MD5, SHA-1, or unsalted SHA-256 hashes, you cannot simply re-hash them without the original passwords. The only time you have the plaintext password is at login.

The standard migration strategy:

  1. Add a column to your users table for the new hash format (or a flag indicating migration status)
  2. On each successful login: verify against the old hash, then immediately re-hash and store the bcrypt hash
  3. After a transition period, you can force-expire accounts that have not logged in and have not been migrated
async function migratingLogin(email, plaintext) {
  const user = await db.getUserByEmail(email);

  // Try bcrypt first (already migrated)
  if (user.password_hash.startsWith('$2')) {
    return bcrypt.compare(plaintext, user.password_hash);
  }

  // Legacy SHA-256 path
  const sha256Hash = crypto.createHash('sha256').update(plaintext).digest('hex');
  const legacyMatch = sha256Hash === user.password_hash;

  if (legacyMatch) {
    // Migrate to bcrypt on successful login
    const newHash = await bcrypt.hash(plaintext, 12);
    await db.updatePasswordHash(user.id, newHash);
    return true;
  }

  return false;
}

Testing Bcrypt Hashes

Our Bcrypt Hash Generator is useful for:

  • Generating test hashes during development without setting up a project
  • Verifying that your implementation produces valid bcrypt output
  • Quickly testing hash-to-password verification in your code
  • Understanding the hash format -- you can adjust the cost factor and see how the output prefix changes

The tool runs entirely in your browser and never sends any data to a server. Useful when you want to prototype or debug without having your full application running.

Bcrypt vs. Alternatives

Bcrypt is proven and widely supported, but it is worth knowing the landscape:

AlgorithmMemory-HardGPU-ResistantOWASP StatusNotes
BcryptNoPartialRecommendedProven, 72-byte limit
Argon2idYesYesPreferredPHC winner, newer
scryptYesYesAcceptableMemory-hard, complex parameters
PBKDF2NoNoAcceptableNIST-approved, FIPS-compliant
SHA-256 (plain)NoNoDo Not UseNot a password hash

For new projects, Argon2id is the current best practice. It is memory-hard, which makes GPU-based attacks much more expensive, and it has three parameters (memory, time, parallelism) that give you more control over the cost profile.

That said, bcrypt has over 25 years of real-world use, is available in virtually every language and framework, and the 72-byte truncation issue is manageable. If your team is more comfortable with bcrypt or your framework has first-class support for it, bcrypt at cost 12+ is a defensible choice.

Conclusion

Password hashing is one of those areas where the difference between "works" and "works correctly" matters a lot. A login form that stores SHA-256 hashes might feel secure until a database breach reveals that all of your users' passwords are now crackable in hours.

Bcrypt gives you three important things: intentional slowness, automatic salting, and a self-contained output format. It is not the newest option, but it is well-tested, universally supported, and correct.

Use cost factor 12 as your starting point. Benchmark it on your actual hardware. Increase it if your server can hash comfortably faster than 200ms. And if you are starting from scratch, take a look at Argon2id.

To generate a bcrypt hash for testing or development, use our Bcrypt Hash Generator -- it runs in your browser and does not transmit your input anywhere.

Frequently Asked Questions

Share this article

XLinkedIn

Related Posts