← Back to Blog

Password Hashing Guide: bcrypt vs argon2 vs scrypt

Why Passwords Must Be Hashed

The password a user submits at signup is plaintext. If the database is exfiltrated — SQL injection, a leaked backup, a malicious insider — plaintext storage means every user's password is exposed instantly. Because a large share of people reuse passwords across sites, a single breach cascades into damage far beyond your own product.

Password hashing exists so that even a full database leak does not hand the attacker the original passwords. All they can do is attempt brute force within a bounded time budget. Hashing is the last line of defence for password security.

Hashing vs Encryption

Property Hashing Encryption
Direction One-way, irreversible Two-way, decryptable
Purpose Password storage Transport secrecy, file encryption
Key Not required Requires a key
Examples bcrypt, argon2 AES, RSA

Never store passwords encrypted. Encryption means that once the key leaks, everything is exposed at once, and key management is strictly more complex than hashing. Hashing is the correct answer.

Why Not MD5 / SHA-256?

General-purpose hash functions (MD5, SHA-1, SHA-256) were designed to be fast — that is what makes them good for file integrity checks and high-throughput indexing. But "fast" is fatal for password storage:

  • A modern GPU computes billions of SHA-256 hashes per second
  • A consumer graphics card can exhaust every password up to eight characters in a few hours
  • MD5 and SHA-1 have demonstrated collision weaknesses and must never be used

Password hashing needs the opposite: algorithms that are deliberately slow and resource-hungry, so every single guess is expensive and brute force becomes economically unattractive.

Our hash generator implements the general-purpose family — useful for checksums and fingerprints, but it is not a password storage scheme.

Core Concepts

Salt

A salt is a random string concatenated with the password before hashing:

hash = bcrypt(password + salt, cost)

What the salt buys you: the same password produces a different hash.

Scenario No salt With salt
Two users both use 123456 Identical hash, crackable in bulk Different hashes, must be cracked one at a time
Rainbow table (precomputed hashes) Direct hit Useless, must be recomputed
Database leak One crack compromises every identical password Each user stands alone

Salt requirements:

  • Unique per user — a user ID does not qualify; use random bytes from a CSPRNG
  • Long enough — at least 16 bytes / 128 bits
  • Not secret — it is stored alongside the hash

Modern password hashing libraries (bcrypt, argon2) generate the salt automatically and embed it in the output, so you never manage it by hand.

Work Factor / Cost

The work factor controls how many iterations or how much resources the hash consumes:

  • bcrypt's cost parameter: 2^cost iterations
  • argon2's time / memory / parallelism parameters
  • scrypt's N / r / p parameters

The core tradeoff:

  • Higher work factor → slower brute force → more secure
  • Higher work factor → slower verification on every login → worse UX and more server load

Recommended baseline: roughly 250 ms to 500 ms per hash. Making a user wait an extra half second at login is acceptable; making an attacker spend decades to exhaust an eight-character password is not.

As hardware improves, the work factor should be raised periodically — see the migration strategies below.

Memory Hardness

Traditional hashes such as bcrypt mostly consume CPU. That is a problem, because attackers can parallelise cheaply and massively using ASICs, FPGAs, and GPUs.

Memory hardness forces the hashing process to occupy a large block of memory (say 64 MB), which means:

  • Ordinary CPUs and GPUs have plenty of memory and work fine
  • An ASIC or FPGA must carry large on-chip memory to parallelise, so hardware cost rises steeply
  • Attackers lose the ability to parallelise cracking on cheap commodity hardware

Both argon2 and scrypt are memory-hard, which is precisely why they are better fits than bcrypt for new projects.

The Three Algorithms in Detail

bcrypt

Born in 1999, still the most widely deployed password hashing algorithm.

How it works: key derivation built on the Blowfish cipher, with a cost parameter controlling 2^cost rounds.

Parameters:

  • cost: 4–31, recommended 12–14
  • salt: 16 bytes, generated automatically

Output format:

$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
  • $2b$ — algorithm marker (2a / 2b / 2y are all bcrypt variants; 2b is the corrected one)
  • 12 — the cost factor
  • The next 22 characters are the Base64-encoded salt
  • The final 31 characters are the Base64-encoded hash

Strengths:

  • Battle-tested through 25 years of production use
  • Library support everywhere, in essentially every language
  • Better ASIC resistance than the SHA family, though still behind argon2

Weaknesses:

  • Not memory-hard; GPU parallel cracking remains effective
  • 72-byte password cap — anything beyond it is truncated, a real hazard in the era of long passphrases
  • Cost ceiling of 31 may not hold up long term

Code:

// Node.js
const bcrypt = require('bcrypt');

const cost = 12;
const hash = await bcrypt.hash('userPassword123', cost);
// $2b$12$...

const match = await bcrypt.compare('userPassword123', hash);
# Python
import bcrypt

hash = bcrypt.hashpw(b'userPassword123', bcrypt.gensalt(rounds=12))
match = bcrypt.checkpw(b'userPassword123', hash)

You can try both directions directly in our bcrypt hash tool, which runs entirely in your browser.

argon2

Winner of the 2015 Password Hashing Competition, and the algorithm we recommend today.

It ships in three variants:

  • argon2id (recommended): a hybrid, balancing side-channel resistance and GPU resistance
  • argon2i: side-channel resistance first, suited to key derivation
  • argon2d: GPU resistance first, but carries side-channel risk

Parameters:

  • memory: memory cost in KB, recommended 65536 (64 MB) or more
  • time: iteration count, recommended 2–3
  • parallelism: thread count, recommended 2–4
  • salt: at least 16 bytes

Output format:

$argon2id$v=19$m=65536,t=3,p=4$c2FsdHlzYWx0...$hashbytes...

Strengths:

  • Memory hardness that defeats ASIC and GPU parallelism
  • Three tuning dimensions (memory, time, parallelism) for flexible tuning
  • Modern design with no 72-byte limit
  • The current first choice of both OWASP and NIST

Weaknesses:

  • Relatively young, though it has been audited extensively
  • Incomplete library support on some older platforms and languages

Recommended parameters (OWASP 2024):

argon2id
  memory: 19 MiB (19456 KB)
  time: 2
  parallelism: 1

Raise to memory=64MB, time=3, parallelism=4 where security requirements are higher.

Code:

// Node.js (argon2)
const argon2 = require('argon2');

const hash = await argon2.hash('userPassword123', {
  type: argon2.argon2id,
  memoryCost: 65536,   // 64 MB
  timeCost: 3,
  parallelism: 4,
});

const match = await argon2.verify(hash, 'userPassword123');
# Python (argon2-cffi)
from argon2 import PasswordHasher, Type

ph = PasswordHasher(
    time_cost=3,
    memory_cost=65536,
    parallelism=4,
    type=Type.ID
)
hash = ph.hash('userPassword123')
match = ph.verify(hash, 'userPassword123')

scrypt

Designed by Colin Percival in 2009, the first mainstream memory-hard KDF.

Parameters:

  • N: CPU/memory cost, must be a power of two, recommended 2^17 = 131072
  • r: block size, recommended 8
  • p: parallelism, recommended 1–4

Strengths:

  • Memory-hard design, a step up from bcrypt
  • Mature algorithm with decent library support

Weaknesses:

  • Parameter tuning is fiddlier than argon2
  • At equivalent security levels, argon2id is generally more efficient
  • No longer OWASP's first recommendation

Code:

// Node.js
const scrypt = require('scrypt-js');

// scrypt's sync API is awkward; prefer a Promise-based wrapper
# Python (hashlib.scrypt, in the standard library)
import hashlib, os, base64

salt = os.urandom(16)
hash = hashlib.scrypt(
    b'userPassword123',
    salt=salt,
    n=2**17,
    r=8,
    p=1,
    dklen=32
)
# Store as: base64(salt) + '$' + base64(hash)

Comparing the Three

Dimension bcrypt scrypt argon2id
Year released 1999 2009 2015
Memory-hard No Yes Yes
GPU/ASIC resistance Medium Strong Strongest
Password length limit 72 bytes None None
Parameter dimensions 1 (cost) 3 (N/r/p) 3+ (m/t/p)
Library support Widest Broad Broad
OWASP recommendation Usable Usable Preferred
Typical time per hash ~250 ms ~300 ms ~300 ms
Side-channel resistance Yes No (d) / yes for i Yes (id covers both)

How to Choose

  • New project: go straight to argon2id — it is the best choice available today
  • Already on bcrypt with no migration pressure: it is fine to stay, but move to argon2id at the next refactor
  • On MD5, SHA, or a homegrown scheme: migrate now — this is a high-severity vulnerability
  • Constrained environments (embedded targets, no argon2 library): bcrypt with cost ≥ 12

Not sure how strong the passwords you are hashing actually are? Check the entropy estimate in our password strength analyser.

Migration Strategies

Scenario 1: Weak Hashes (MD5/SHA) to bcrypt/argon2

Core idea: lazy migration — upgrade the hash the next time the user logs in, with no batch recomputation.

1. The user logs in and submits a plaintext password
2. The server computes the old algorithm (e.g. MD5) and compares
   - no match -> reject the login
   - match    -> continue to step 3
3. Re-hash the plaintext with the new algorithm (e.g. argon2id)
4. Update the hash column in the database
5. Flag the user as migrated (e.g. a hash_version column)

Schema:

ALTER TABLE users ADD COLUMN hash_version INT DEFAULT 0;
-- 0 = MD5 (pending migration), 1 = argon2id (migrated)

Pseudocode:

def verify_password(user, plaintext):
    if user.hash_version == 0:
        # Verify with the old algorithm
        if md5(plaintext) != user.password_hash:
            return False
        # Migrate to argon2id
        user.password_hash = argon2_hash(plaintext)
        user.hash_version = 1
        db.commit()
        return True
    elif user.hash_version == 1:
        return argon2_verify(user.password_hash, plaintext)

Why this works:

  • Non-blocking; no batch job over every password
  • Gradual — each returning user is upgraded automatically
  • Users who never log in can still be verified against the old hash

Caveat: for accounts dormant for a very long time, consider forcing a password reset.

Scenario 2: Raising the bcrypt Cost Factor

As hardware gets faster, yesterday's cost value stops being safe. Use the same lazy approach:

def verify_password(user, plaintext):
    if bcrypt_check(plaintext, user.password_hash):
        # Check whether the cost needs raising
        current_cost = extract_cost(user.password_hash)
        if current_cost < TARGET_COST:
            user.password_hash = bcrypt_hash(plaintext, TARGET_COST)
            db.commit()
        return True
    return False

Scenario 3: bcrypt to argon2id

def verify_password(user, plaintext):
    if user.hash_version == 0:
        # Verify with bcrypt
        if not bcrypt_check(plaintext, user.password_hash):
            return False
        # Upgrade to argon2id
        user.password_hash = argon2_hash(plaintext)
        user.hash_version = 1
        db.commit()
    elif user.hash_version == 1:
        return argon2_verify(user.password_hash, plaintext)
    return True

Migration Cautions

  1. Keep the old hash until migration completes — do not delete it before every active user has moved over
  2. Monitor progress — track the distribution of hash_version and how many users remain
  3. Notify dormant users — email them to log in and trigger the migration, or require a reset
  4. Roll out gradually — validate the migration logic on a small cohort first, so a bug cannot lock users out
  5. Have a rollback plan — retain the old hash column so you can fall back if the new algorithm misbehaves

Implementation Notes and Common Pitfalls

1. Generate Salts with a CSPRNG

# Wrong: never use the random module
import random
salt = str(random.randint(0, 999999))

# Right: cryptographically secure randomness
import os
salt = os.urandom(16)

The hash() methods of bcrypt and argon2 libraries already use a CSPRNG internally, so you rarely need to do this yourself.

2. Constant-Time Comparison

Always compare hashes with a constant-time function to prevent timing attacks:

# Wrong: plain == leaks information through timing
if user_hash == input_hash:

# Right: constant-time comparison
import hmac
if hmac.compare_digest(user_hash, input_hash):

The verify() methods of bcrypt and argon2 libraries are already constant-time internally, so calling them is enough.

3. Password Length Limits

  • bcrypt: truncates at 72 bytes. For long passwords, a common workaround is SHA-256 first and then bcrypt — but that changes the strength profile, so evaluate it deliberately
  • argon2 / scrypt: no practical limit

4. Concurrency and Performance

Password hashing is CPU-bound:

  • A login occupies CPU for roughly 250 ms, which becomes a bottleneck under high concurrency
  • Rate-limit the login endpoint (for example 10 attempts per IP per minute) so brute force cannot also exhaust your CPU
  • Offload bulk hashing to an async queue

5. Do Not Leak Information in Error Messages

# Wrong: reveals whether the account exists
if user not found:
    return "User does not exist"
if password wrong:
    return "Incorrect password"

# Right: one uniform message
return "Incorrect username or password"

6. Storage Format

Encode the algorithm, parameters, and salt into a single string so migration and multi-algorithm coexistence stay simple:

$argon2id$v=19$m=65536,t=3,p=4$base64salt$base64hash
$2b$12$22charsalt31charhash

Dispatch on the prefix at verification time and call the matching routine.

Summary

Dimension Best practice
Algorithm for new projects argon2id (OWASP's first choice)
Existing bcrypt Acceptable to keep; migrate at next refactor
MD5 / SHA / plaintext Migrate immediately — high severity
Salt Unique per user, CSPRNG-generated, ≥ 16 bytes
Work factor ~250–500 ms per hash
Memory hardness argon2 / scrypt beat bcrypt
Migration approach Lazy migration (upgrade at login)
Cost tuning Review periodically; raise as hardware improves
Comparison Constant-time comparison
Error messages One uniform "incorrect username or password"

Password hashing is the foundation of account security. Choose the right algorithm, tune the parameters, and handle migration properly — only then do you actually protect users when the database leaks. Never invent your own hashing scheme. Use an audited standard library and follow OWASP's current guidance.

For a side-by-side comparison of algorithm strength and output sizes, see the hash and encryption algorithm cheat sheet.

Advertisement

Frequently Asked Questions

Which should I choose: bcrypt, scrypt, or argon2?

Use **argon2id** for new projects. It is what OWASP and NIST currently recommend: it is memory-hard, so it resists GPU and ASIC parallel cracking effectively. An existing bcrypt deployment can stay if there is no migration pressure (keep cost at 12 or above) and move over at the next refactor. In constrained environments (embedded targets, no argon2 library), bcrypt is acceptable. **MD5, the SHA family, or a homegrown scheme is high-risk and must be migrated immediately.**

How large should the work factor be?

Tune backwards from a **single-hash time of 250–500 milliseconds** rather than copying someone else's numbers — machine performance varies enormously. For argon2id, start at `memory=19 MiB, time=2, parallelism=1` (the OWASP 2024 baseline) and raise it to `memory=64MB, time=3, parallelism=4` for higher-security cases. For bcrypt, use `cost=12~14`. Whatever you pick, measure the actual time on hardware matching your production spec, and revisit it as hardware gets faster.

What is lazy migration, and why can't I just rehash everything in a batch?

A batch rehash needs the plaintext password, but the server only stores the hash — **you can rehash only if you have the plaintext, and having it is exactly what you do not have.** Lazy migration sidesteps that deadlock: when a user logs in they hand you the plaintext, so you verify with the old algorithm, rehash with the new one, and write it back — one login completes the upgrade. It needs no downtime and no batch job, and inactive accounts simply stay on the old hash until they return. For accounts dormant for a long time, prompt a login or force a password reset.

What problems does bcrypt's 72-byte limit cause?

bcrypt consumes only the first 72 bytes of a password and silently discards the rest. That means very long passwords — long passphrases, or long strings generated by a password manager — have their effective strength truncated, with the user completely unaware. A common workaround is to SHA-256 the password first and feed that to bcrypt, but that introduces its own design tradeoffs you should evaluate yourself. argon2 and scrypt have no such limit.

← Back to Blog