DevToys Web Pro iconDevToys Web ProBlog
Oversat med LocalePack logoLocalePack
Bedøm os:
Prøv browserudvidelsen:
← Back to Blog

bcrypt Guide: Password Hashing, Salt Rounds, and Cost Factor Explained

10 min read

Passwords must never be stored as plain SHA256. A SHA256 hash is fast by design — and fast is exactly what attackers want. If your database leaks, an attacker with modern GPU hardware can test billions of SHA256 guesses per second against every password hash simultaneously. bcrypt was designed in 1999 specifically to resist this attack. Use the bcrypt Generator to follow along and experiment with cost factors.

For the broader context on why hashing algorithms differ by purpose, see Hashes, Encryption, and Password Hashing and the Hashing Algorithms Comparison. If you are generating the passwords that users set, the Password Generator Guide covers entropy and character set tradeoffs.

Why Fast Hashes Are the Wrong Tool for Passwords

SHA256 processes data at roughly 10–15 GB/s on a modern CPU — and modern GPUs can compute around 10 billion SHA256 hashes per second. An 8-character lowercase password has about 200 billion possible values. That exhaustive search takes under 20 seconds on commodity hardware.

bcrypt at cost factor 12 produces approximately 5 hashes per second on that same hardware. The same 200-billion-combination search now takes over 1,000 years. The attacker gains nothing from GPU parallelism because bcrypt is deliberately memory-hard and uses an algorithm (Blowfish key setup) that does not vectorize efficiently.

The principle is called key stretching: you make the hash function artificially slow so that an offline attacker who steals your password database faces an impractical amount of computation, even with modern hardware. The legitimate user logging in waits ~250ms. The attacker trying 10 billion combinations waits decades.

bcrypt Hash Anatomy

A bcrypt hash string looks like this:

$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewXt.q/qRLFpKxuy

Every field in that string carries meaning:

SegmentValue in exampleMeaning
$2b$2bAlgorithm version. 2b is the current standard (fixes a Unicode bug in older 2a)
1212Cost factor (log₂ of iterations: 2¹² = 4,096 rounds of key setup)
Salt (22 chars)LQv3c1yqBWVHxkd0LHAkCO128-bit random salt, base64-encoded with bcrypt's custom alphabet
Hash (31 chars)Yz6TtxMQJqhN8/LewXt.q/qRLFpKxuyThe actual 184-bit output, base64-encoded

The entire 60-character string is self-contained. You store it as a single column in your database. Verification reads the version, cost, and salt from it — you never need to store the salt separately.

Cost Factor Math

The cost factor is a base-2 exponent. Cost 12 means 2¹² = 4,096 rounds of Blowfish key setup. Each increment doubles the work:

CostRoundsRelative workApprox. time (modern server)
101,024~65 ms
112,048~130 ms
124,096~250 ms
138,192~500 ms
1416,38416×~1,000 ms

The OWASP recommendation is to target ~250ms per hash on your production hardware. Cost 12 hits that target on most cloud instances in 2024–2026. As hardware gets faster, you should increase the cost factor at regular intervals (more on that below).

Going from cost 10 to cost 14 is a 16× increase in work for the attacker — but only a 16× increase for you too. At cost 14 with 1,000 logins per second, you need ~1,000 CPU cores dedicated just to bcrypt. This is why the 250ms target exists: balance security against infrastructure cost.

Built-in Salting

bcrypt generates a fresh 128-bit cryptographically random salt for every hash operation. You never call bcrypt.hash(password, salt) with a manually crafted salt — you pass the cost factor and the library handles salt generation internally.

This matters because salts defeat precomputed rainbow tables. Without a salt, an attacker could precompute hashes for every common password once and look up results in a table. With a unique per-user salt, they must recompute from scratch for every single account — the rainbow table approach becomes worthless.

What about pepper? A pepper is a secret key mixed into the hash before storage, held in application memory (not in the database). Pepper adds defense-in-depth: if only the database leaks, the attacker still needs the pepper to verify guesses. The downside is operational complexity — rotating a pepper requires re-hashing every password. It is not part of the bcrypt standard and most deployments skip it. If you want pepper-like protection, consider wrapping the bcrypt output with HMAC.

The 72-Byte Password Truncation

bcrypt has a well-known limitation: it silently truncates passwords longer than 72 bytes. Any characters beyond byte 72 are ignored during hashing. This means that two passwords sharing the same first 72 bytes will produce the same bcrypt hash — a collision that undermines security for very long passwords.

In practice this rarely matters: most users choose passwords well under 72 characters. But if you allow passphrases or system-generated high-entropy strings, be aware.

The common workaround is pre-hashing:

import { createHash } from 'crypto';
import bcrypt from 'bcryptjs';

// Pre-hash with SHA256 to base64 (always 44 bytes — under 72)
function prehash(password: string): string {
  return createHash('sha256').update(password).digest('base64');
}

// Hash
const hash = await bcrypt.hash(prehash(password), 12);

// Verify
const valid = await bcrypt.compare(prehash(password), hash);

The pre-hash collapses any input to a fixed 44-character base64 string, safely under the 72-byte limit. Note: base64 output rather than hex is preferred here because base64 uses a wider character set (64 symbols vs 16), preserving more entropy per byte within the 44-char result.

bcrypt vs Argon2id vs scrypt

bcrypt is not the only password hashing algorithm. OWASP's current primary recommendation is Argon2id, the winner of the 2015 Password Hashing Competition. Here is how they compare:

AlgorithmMemory parameterTime parameterParallelismOWASP stance
bcryptFixed ~4 KBCost factor (log₂ rounds)NoneAcceptable; widely supported
Argon2idConfigurable (64 MB+ recommended)IterationsConfigurable threadsFirst choice for new systems
scryptConfigurable (N, r, p)N (CPU/memory cost)p factorAcceptable; harder to tune
PBKDF2NoneIteration countNoneAcceptable only at very high iteration counts (600k+ for SHA256)

Argon2id's memory-hardness is its key advantage: it requires gigabytes of RAM per hash, which makes GPU and ASIC attacks dramatically more expensive. bcrypt's fixed ~4 KB memory footprint means a high-end GPU can still run many parallel bcrypt computations per second, even at cost 12.

When to stick with bcrypt: if your platform's crypto library does not yet support Argon2id (notably PHP before 8.0, older Node versions without native bindings), or if you are maintaining a legacy system where changing algorithms requires a migration strategy. bcrypt at cost 12 is not broken — it is just not the state of the art.

Code Examples

Node.js (bcryptjs / bcrypt)

npm install bcryptjs
import bcrypt from 'bcryptjs';

// Hash a password (cost factor 12)
const hash = await bcrypt.hash(password, 12);

// Verify — always use bcrypt.compare, never re-hash and compare strings
const isValid = await bcrypt.compare(password, hash);
console.log(isValid); // true

The native bcrypt package (compiled C bindings) is faster for high-throughput servers; bcryptjs is pure JavaScript and easier to install in environments without a C compiler.

Python

pip install bcrypt
import bcrypt

# Hash
password_bytes = password.encode('utf-8')
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt(rounds=12))

# Verify
is_valid = bcrypt.checkpw(password_bytes, hashed)
print(is_valid)  # True

PHP

<?php
// Hash — PASSWORD_BCRYPT uses cost 10 by default
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verify
$isValid = password_verify($password, $hash);
var_dump($isValid); // bool(true)
?>

PHP's password_hash / password_verify API is the preferred interface — it handles algorithm selection, salt generation, and future-proofing. The underlying algorithm can be swapped to PASSWORD_ARGON2ID without changing your verification code.

Go

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

// Hash
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
    return err
}

// Verify
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
isValid := err == nil

Verification Workflow

The most important rule: always use the library's compare function, never re-hash and compare strings. The reasons are subtle:

  • Salt extraction: The compare function extracts the salt from the stored hash string and reuses it. If you call bcrypt.hash(password, cost) again, a new random salt is generated and the output will never match.
  • Constant-time comparison: The compare function uses a timing-safe byte comparison internally. A naive === string comparison short-circuits on the first differing character, leaking information about how many leading characters matched.
  • Store the full hash string: The 60-character bcrypt output — version, cost, salt, and hash — must be stored as a single string. Do not split or store the salt in a separate column.
// Correct
const isValid = await bcrypt.compare(inputPassword, storedHash);

// Wrong — generates new salt, will never match
const rehashed = await bcrypt.hash(inputPassword, 12);
const isValid = rehashed === storedHash; // always false

Upgrading Cost Factor Over Time

As hardware gets faster, cost 12 in 2026 will eventually become cost 12 in 2036 — and hash ten times faster than it does today. OWASP recommends auditing your cost factor every two years and increasing it if hashing has dropped below 250ms on current production hardware.

The migration strategy is transparent and requires no user interaction: on each successful login, check the cost factor embedded in the stored hash. If it is below the current target, re-hash the plaintext password (which you have in memory for that request only) with the new cost and update the stored hash.

const CURRENT_COST = 12;

async function login(inputPassword: string, storedHash: string) {
  const isValid = await bcrypt.compare(inputPassword, storedHash);
  if (!isValid) return false;

  // Check if the stored hash uses an outdated cost factor
  const rounds = bcrypt.getRounds(storedHash); // reads cost from hash string
  if (rounds < CURRENT_COST) {
    const newHash = await bcrypt.hash(inputPassword, CURRENT_COST);
    await db.updatePasswordHash(userId, newHash);
  }

  return true;
}

This approach upgrades hashes lazily as users log in. Inactive accounts retain their old cost hashes; you can force a re-hash on next login or require a password reset for accounts that have not logged in for an extended period.


Hash and verify bcrypt passwords directly in your browser with the bcrypt Generator — no server, no data leaving your machine. Adjust the cost factor and observe the timing impact in real time.