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
costparameter:2^costiterations - argon2's
time/memory/parallelismparameters - scrypt's
N/r/pparameters
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–14salt: 16 bytes, generated automatically
Output format:
$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
$2b$— algorithm marker (2a/2b/2yare all bcrypt variants;2bis 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 moretime: iteration count, recommended 2–3parallelism: thread count, recommended 2–4salt: 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 = 131072r: block size, recommended 8p: 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
- Keep the old hash until migration completes — do not delete it before every active user has moved over
- Monitor progress — track the distribution of
hash_versionand how many users remain - Notify dormant users — email them to log in and trigger the migration, or require a reset
- Roll out gradually — validate the migration logic on a small cohort first, so a bug cannot lock users out
- 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.