Cryptography

Password Hashing (bcrypt, Argon2)

Make the hash so slow that a stolen database is worthless

Password hashing stores a password as a deliberately slow, salted one-way hash (bcrypt, scrypt, Argon2) so that a database breach hands attackers a useless digest instead of the plaintext, and brute-forcing each password costs hundreds of milliseconds.

  • DirectionOne-way (irreversible)
  • Per-user salt16+ random bytes
  • Target verify time~250–500 ms
  • bcrypt input cap72 bytes
  • 2026 defaultArgon2id

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The core idea: slow, salted, one-way

You never store what the user typed. When someone signs up, you transform their password through a one-way function and store only the output — a fixed-length digest from which the original cannot be recovered. At login you run the same transform on what they type and compare digests. If a hacker steals your database, all they get is a column of digests, not passwords.

That's the part most people get right. The part most people get wrong is which function to use. The instinct is to reach for a cryptographic hash like SHA-256 or MD5 — they're one-way, fast, and built in. That instinct is exactly backwards. A general-purpose hash is engineered to be fast: a single consumer GPU computes on the order of tens of billions of SHA-256 hashes per second. Against a leaked digest, an attacker simply guesses passwords and hashes each guess until the digests match. At billions of guesses per second, every common password falls in seconds.

Password hashing flips two dials that a general-purpose hash leaves at zero:

  • A salt. A unique random value per user, prepended to the password before hashing. Because the salt differs per user, two people with the same password get completely different digests, and a precomputed lookup table (a rainbow table) is useless — the attacker would have to rebuild it for every salt. The salt is not secret; it's stored in plaintext right next to the hash. Its only job is uniqueness.
  • A work factor. A tunable cost that makes a single hash deliberately expensive — hundreds of milliseconds of CPU, and for memory-hard functions, hundreds of megabytes of RAM. A 300 ms verify time is invisible to a logging-in user but catastrophic for an attacker: it cuts their guess rate from billions per second to a few per second per core.

The three algorithms you should actually use — bcrypt (1999), scrypt (2009), and Argon2 (2015) — all bake the salt and the work factor directly into a self-describing output string, so the stored hash carries everything verify() needs.

How the work factor actually works

The defining trick of a good password hash is configurable slowness, and the three algorithms achieve it differently.

bcrypt exposes a single parameter, the cost (or work factor) — an integer typically between 10 and 14. Internally bcrypt runs an expensive key-setup phase of the Blowfish cipher 2^cost times. So cost 12 is exactly twice the work of cost 11 and 4× the work of cost 10 — the cost is exponential in the parameter. A bcrypt hash looks like:

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 │   │  └ 22-char base64 salt + 31-char base64 hash ────────┘
 │   └ cost = 12  (2^12 = 4096 key-setup rounds)
 └ algorithm version (2b)

scrypt adds memory hardness. Its three parameters are N (CPU/memory cost, a power of two), r (block size), and p (parallelism). Memory used is roughly 128 · N · r bytes. The point of forcing memory use is to neutralize the attacker's hardware advantage: a GPU has thousands of cores but limited RAM per core, and an ASIC's whole edge is cheap silicon — both stall when each guess demands hundreds of megabytes.

Argon2, the winner of the 2015 Password Hashing Competition, separates the dials cleanly:

  • m — memory in KiB (e.g. 65536 = 64 MiB, or 19456 = 19 MiB for the interactive minimum).
  • t — iterations (time cost), passes over the memory.
  • p — degree of parallelism (lanes / threads).

It comes in three flavors: Argon2d (data-dependent memory access, maximally GPU-resistant but vulnerable to side-channel timing attacks), Argon2i (data-independent, side-channel-safe but weaker against time-memory tradeoffs), and Argon2id — a hybrid that runs the first half of the first pass in i-mode then switches to d-mode for the rest. Argon2id is the recommended default, codified in RFC 9106. An Argon2id output is fully self-describing:

$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
 │         │    └ m=64MiB, t=3, p=4   └ b64 salt   └ b64 hash
 │         └ Argon2 version 0x13 (19)
 └ variant = id

Because the parameters live inside the string, raising the work factor later doesn't break old hashes — verify() reads each hash's own cost. There's no separate complexity bound to memorize the way there is for a sorting algorithm: the whole design is that the cost is whatever you choose, and you raise it as hardware gets faster.

When to use which (and when not to hash at all)

  • New systems → Argon2id. Memory hardness is the strongest defense against GPU and ASIC cracking rigs. Start at OWASP's baseline (m = 19 MiB, t = 2, p = 1) and tune upward until a verify takes ~250–500 ms on your server.
  • Memory-constrained or legacy environments → bcrypt. 25+ years of scrutiny, tiny memory footprint, available everywhere. Cost 12 is a reasonable 2026 baseline. Just respect the 72-byte cap.
  • scrypt is a solid memory-hard option where Argon2 isn't available, and it's the function behind many cryptocurrency and disk-encryption KDFs.
  • PBKDF2 only when you're forced into FIPS-140 compliance — it's not memory-hard, so it needs a very high iteration count (≥600,000 for HMAC-SHA-256 per 2023 OWASP guidance) and is the weakest of the four.

Do not use a password hash for things that aren't human passwords. High-entropy random API keys and session tokens don't need a slow hash — they have 128+ bits of entropy, so brute force is already impossible; a fast SHA-256 (or HMAC) is correct there, because adding 300 ms per token lookup would just throttle your own server.

bcrypt vs scrypt vs Argon2 vs SHA-256

SHA-256 (raw)PBKDF2bcryptscryptArgon2id
Year20012000 (RFC 2898)199920092015
Built for passwords?No — too fastYesYesYesYes
Tunable CPU costNoYes (iterations)Yes (cost, exponential)Yes (N)Yes (t)
Memory-hardNoNoMild (4 KB fixed)Yes (128·N·r)Yes (tunable m)
Built-in salt handlingNo (DIY)ManualYes (embedded)Yes (embedded)Yes (embedded)
Input length limitNoneNone72 bytes (silent)NoneNone
GPU/ASIC resistanceNonePoorModerateStrongStrongest
Side-channel safetyn/aYesYesYesYes (id hybrid)
2026 recommendationNever for passwordsFIPS onlySafe fallbackGood alternativePreferred default

The headline split is memory hardness. SHA-256 and PBKDF2 burn only CPU, which is exactly what attacker GPUs have in abundance. bcrypt forces a fixed 4 KB working set — enough to slow GPUs somewhat. scrypt and Argon2 let you demand tens or hundreds of megabytes per guess, which is where a $2,000 cracking GPU loses its thousand-fold parallelism advantage.

What the numbers actually say

  • Raw SHA-256: ~22 billion hashes/sec on a single RTX 4090. An 8-character lowercase-alphanumeric password (≈ 2.8 trillion combinations) falls in about 2 minutes of brute force.
  • bcrypt cost 12: ~50–100 hashes/sec on that same GPU. The identical 8-character space now takes on the order of 900 years — a slowdown of roughly 200-million-fold versus raw SHA-256.
  • Argon2id at 64 MiB: a few hundred guesses/sec per GPU at best, because the card's VRAM can only hold a handful of 64 MiB working sets in parallel — its thousands of cores sit idle waiting on memory. That's the entire point of memory hardness.
  • Doubling bcrypt cost = doubling attacker time. Going from cost 10 to cost 14 multiplies the attacker's effort by 2⁴ = 16× for one extra config-file character.
  • Salt cost is essentially zero — 16 random bytes per user — yet it forces the attacker from one shared rainbow table to a separate brute-force run per account. Against a 1-million-row breach, that's the difference between cracking everyone at once and cracking each victim individually.

JavaScript implementation

In Node.js, use a vetted library — never hand-roll the algorithm. argon2 (npm) or the built-in crypto.scrypt for memory-hard hashing, or bcrypt for the classic. Here is the full register / verify / upgrade lifecycle with Argon2id:

import argon2 from 'argon2';

// Tune these so hashPassword() takes ~300 ms on YOUR server.
const OPTS = {
  type: argon2.argon2id,
  memoryCost: 1 << 16,  // 65536 KiB = 64 MiB
  timeCost: 3,          // 3 passes
  parallelism: 4,       // 4 lanes
};

// On signup. The salt is generated internally and embedded in the output.
async function hashPassword(plain) {
  return argon2.hash(plain, OPTS);   // → "$argon2id$v=19$m=65536,t=3,p=4$...$..."
}

// On login. verify() parses the parameters out of the stored hash itself,
// and compares in constant time to avoid timing leaks.
async function checkLogin(plain, stored) {
  const ok = await argon2.verify(stored, plain);
  if (!ok) return { ok: false };

  // Transparently upgrade old/weak hashes while we hold the plaintext.
  if (argon2.needsRehash(stored, OPTS)) {
    const upgraded = await hashPassword(plain);
    await db.users.update({ passwordHash: upgraded });
  }
  return { ok: true };
}

Two things make this safe. First, verify() does a constant-time comparison — comparing digests with === can leak how many leading bytes matched via timing, so libraries use crypto.timingSafeEqual internally. Second, needsRehash() reads the cost baked into the old hash and silently migrates the user to your current parameters on their next successful login — no mass password reset required.

If you must use bcrypt, defuse the 72-byte truncation first by pre-hashing:

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

// SHA-256 → base64 collapses any-length input to 44 bytes, under bcrypt's 72-byte cap,
// so long passphrases aren't silently truncated.
const prep = pw => createHash('sha256').update(pw).digest('base64');

const hash   = (pw)        => bcrypt.hash(prep(pw), 12);     // cost = 12
const verify = (pw, stored) => bcrypt.compare(prep(pw), stored);

Python implementation

Python's ecosystem mirrors Node's. The cleanest path is argon2-cffi, whose PasswordHasher handles salting, encoding, constant-time verification, and rehash detection for you:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError

# Tune so hash() takes ~300 ms on your server.
ph = PasswordHasher(
    time_cost=3,          # t — passes
    memory_cost=64 * 1024,  # m — 64 MiB in KiB
    parallelism=4,        # p — lanes
)

def hash_password(plain: str) -> str:
    return ph.hash(plain)   # random salt generated + embedded automatically

def check_login(plain: str, stored: str) -> bool:
    try:
        ph.verify(stored, plain)        # constant-time; raises on mismatch
    except (VerifyMismatchError, InvalidHashError):
        return False
    # Upgrade transparently if our parameters have since increased.
    if ph.check_needs_rehash(stored):
        db_update(password_hash=ph.hash(plain))
    return True

To see why a salt defeats rainbow tables, here is the bare mechanism with the standard library's hashlib.scrypt — note that we generate a fresh salt per password and store it alongside the digest:

import hashlib, os, hmac

def hash_scrypt(plain: str) -> str:
    salt = os.urandom(16)                       # 16 random bytes, NOT secret
    dk = hashlib.scrypt(plain.encode(), salt=salt,
                        n=2**15, r=8, p=1,       # ~32 MiB working set
                        dklen=32)
    return f"{salt.hex()}${dk.hex()}"           # store salt + digest together

def verify_scrypt(plain: str, stored: str) -> bool:
    salt_hex, dk_hex = stored.split("$")
    salt = bytes.fromhex(salt_hex)
    dk = hashlib.scrypt(plain.encode(), salt=salt,
                        n=2**15, r=8, p=1, dklen=32)
    # constant-time compare — never use == on secrets
    return hmac.compare_digest(dk, bytes.fromhex(dk_hex))

Variants and reinforcements worth knowing

Pepper. A site-wide secret mixed into every hash but stored outside the database — in an HSM, a KMS, or an app-server environment variable. If only the database leaks (the common case for SQL injection), the pepper is missing and the stolen hashes are uncrackable. Implement it as an outer HMAC(pepper, password) before the slow hash, or encrypt the final digest with a key-managed cipher.

Argon2d / Argon2i / Argon2id. As above: d is fastest and most GPU-resistant but leaks via cache-timing side channels; i is side-channel-safe but weaker; id is the hybrid default. Unless you have a specific reason, use id.

Balloon hashing. A 2016 memory-hard design with a clean security proof, built on a standard hash. Niche but academically interesting as an Argon2 alternative.

Server relief / "blind" hashing. Do the expensive hash on the client and a fast hash on the server, so a busy server can't be DoS'd by forcing thousands of 300 ms hashes. Trades a little security for throughput; used carefully in a few large deployments.

Key stretching for KDFs. The same slow-hash machinery (scrypt, Argon2, PBKDF2) is used to derive encryption keys from passwords in disk encryption and password managers — there the "verify" step is replaced by feeding the derived bytes into a cipher.

Common bugs and edge cases

  • Using a fast hash (MD5/SHA-1/SHA-256) for passwords. The single most common breach amplifier — it turns a database leak into instant mass cracking. This is the bug behind nearly every "X million passwords cracked" headline.
  • A single global salt, or no salt. A shared salt re-enables rainbow tables and reveals which users share a password. The salt must be random and per-user.
  • Comparing hashes with ==. A naive byte-by-byte compare returns early on the first mismatch, leaking the match length via timing. Always use a constant-time compare (crypto.timingSafeEqual, hmac.compare_digest). Reputable libraries do this for you.
  • Ignoring bcrypt's 72-byte cap. Long passphrases get silently truncated, so two different passwords can hash identically. Pre-hash with SHA-256+base64, or switch to Argon2/scrypt.
  • The NUL-byte bug. Some old bcrypt implementations stop at the first NUL byte; combined with truncation, a password like "\0anything" could match an empty password. Use a maintained library.
  • Setting the work factor too low "for performance." If login feels instant, it's too cheap for the attacker too. Target ~250–500 ms per verify and revisit the parameters yearly as hardware speeds up.
  • Memory cost as a DoS vector. Argon2 at 1 GiB × many concurrent logins can exhaust server RAM. Size m and p against your real login concurrency, and rate-limit authentication endpoints.
  • Logging the plaintext. The password often passes through request logs, error traces, or analytics before it's hashed. Scrub it at the edge — a hash in the database is worthless if the password sits in a log file.

Frequently asked questions

Why not just use SHA-256 to hash passwords?

Because SHA-256 is designed to be fast. A single modern GPU computes tens of billions of SHA-256 hashes per second, so an attacker can test billions of password guesses per second against a leaked digest. Password hashes are intentionally slow — bcrypt and Argon2 spend hundreds of milliseconds and, in Argon2's case, hundreds of megabytes of memory per guess, collapsing an attacker's guess rate from billions per second to thousands.

What does the salt actually protect against?

A unique random salt per user means identical passwords produce different hashes, so an attacker can't tell which accounts share a password and can't use a precomputed rainbow table — every table would have to be rebuilt per salt. The salt is stored in plaintext alongside the hash; its job is uniqueness, not secrecy.

Is bcrypt or Argon2 better in 2026?

Argon2id is the modern default and the winner of the 2015 Password Hashing Competition — it is memory-hard, so GPUs and ASICs gain far less of an edge. bcrypt is still considered safe and battle-tested for over 25 years, but it is only mildly memory-hard (4 KB) and caps passwords at 72 bytes. Use Argon2id for new systems; bcrypt is a fine, conservative fallback.

What is a pepper and how is it different from a salt?

A pepper is a secret value mixed into every hash but stored separately from the database — in an HSM, a KMS, or an environment variable on the app server. If only the database leaks, the pepper is missing, so the stolen hashes can't be cracked at all. A salt, by contrast, is stored next to the hash and is not secret; it only guarantees uniqueness.

How do you upgrade the work factor without forcing every user to reset their password?

On a successful login you already have the plaintext in memory. Re-hash it with the new, stronger parameters and overwrite the stored digest. The cost and parameters are encoded inside the hash string itself, so verify() always knows how to check old hashes while new logins silently migrate to the stronger setting.

Why does bcrypt silently truncate passwords at 72 bytes?

bcrypt's underlying Blowfish key schedule only consumes the first 72 bytes of input; anything past that is ignored. So a long passphrase and the same string with extra trailing characters can hash identically. The common fix is to pre-hash the password with SHA-256 and base64-encode it before feeding it to bcrypt, or to use Argon2/scrypt, which have no such limit.