How to Store Passwords Securely: Hashing, Salting, and Modern Best Practices
How to Store Passwords Securely: Hashing, Salting, and Modern Best Practices
Storing passwords in plaintext is a disaster waiting to happen. Learn how password hashing works, why Argon2id is the gold standard, and how to implement secure password storage.
Why Password Hashing Matters
Every database breach teaches the same lesson: if passwords are stored insecurely, every user account is compromised. The question isn't whether your database will be breached — it's whether the passwords will be usable by attackers when it happens.
The password storage spectrum (worst to best):
- Plaintext —
password123→ Instantly compromised. Every affected user's account is taken over immediately. - Encrypted —
aGVsbG8=→ If the encryption key is stolen (and it's often stored alongside the database), all passwords are decrypted at once. - Unsalted fast hash (MD5/SHA) —
5f4dcc3b5aa765d61d8327deb882cf99→ Crackable in seconds using rainbow tables or GPU-based attacks. - Salted fast hash — Defeats rainbow tables but still crackable at billions of guesses per second with GPUs.
- Slow hash (bcrypt/scrypt/Argon2) — Designed to be computationally expensive. Billions of guesses become thousands or hundreds. This is the correct approach.
Real breach statistics:
- RockYou (2009): 32 million passwords stored in plaintext. All compromised instantly.
- LinkedIn (2012): 117 million passwords hashed with unsalted SHA-1. Cracked within hours.
- Adobe (2013): 153 million passwords encrypted (not hashed) with 3DES-ECB. The same password produced the same ciphertext, revealing patterns instantly.
- Dropbox (2012, disclosed 2016): 68 million passwords — half in bcrypt (secure), half in SHA-1 (vulnerable). The SHA-1 portion was cracked; the bcrypt portion remained secure.
The Dropbox case is the perfect illustration: the choice of hashing algorithm is the difference between a security incident and a catastrophe.
How Password Hashing Works
A hash function is a one-way mathematical function that converts input of any size into a fixed-size output (the hash). Key properties:
- One-way: Given the hash, you cannot compute the original input. There is no "unhashing."
- Deterministic: The same input always produces the same hash.
- Avalanche effect: A tiny change in input produces a completely different hash.
- Fixed length: Regardless of input size, the output has a fixed length (e.g., 256 bits for SHA-256).
Password verification without storing the password:
- User creates account with password "correcthorse"
- Server computes
hash("correcthorse")→a1b2c3d4...(hash stored in database) - User logs in later, enters "correcthorse"
- Server computes
hash("correcthorse")→a1b2c3d4... - Server compares the computed hash with the stored hash — they match → login successful
- If the user types "wrongpassword",
hash("wrongpassword")→x9y8z7w6...— doesn't match → login denied
The server never stores the actual password. Even if an attacker steals the entire database, they have hashes — not passwords. To recover a password from its hash, the attacker must guess passwords one at a time, hash each guess, and compare.
Why general-purpose hashes (MD5, SHA) are insufficient: General-purpose hash functions like MD5 and SHA-256 are designed to be fast. A modern GPU can compute:
- MD5: ~40 billion hashes per second
- SHA-256: ~5 billion hashes per second
At 5 billion guesses per second, an 8-character password using lowercase letters and numbers is cracked in under 3 minutes. That's why fast hashes are not suitable for password storage.
Salting: Defeating Rainbow Tables
A salt is a random value added to each password before hashing. Each user gets a unique salt, which is stored alongside the hash.
Without salt (vulnerable to rainbow tables):
A rainbow table is a precomputed lookup table mapping common passwords to their hashes. If an attacker has precomputed hash("password123") = 5f4dcc..., they can instantly match any user whose password hashes to 5f4dcc....
With salt:
- User A: password = "password123", salt = "x7k9a2" →
hash("x7k9a2password123")→a1b2c3... - User B: password = "password123", salt = "m3p5q8" →
hash("m3p5q8password123")→f9e8d7...
Even though both users have the same password, their hashes are completely different because of the different salts. Rainbow tables are useless because the attacker would need a separate rainbow table for every possible salt value.
Salt requirements:
- Must be unique per user — never reuse salts
- Must be randomly generated using a cryptographically secure random number generator (CSPRNG)
- Must be long enough — at least 16 bytes (128 bits)
- Must be stored alongside the hash — the salt is not a secret (it just defeats precomputation)
- Generated by the hashing algorithm — modern password hashing functions (bcrypt, Argon2) generate and embed the salt automatically
Important: Salting solves the rainbow table problem but doesn't solve the GPU speed problem. Even with unique salts, an attacker can still try billions of guesses per second against each hash using fast algorithms like SHA-256. That's why we need key stretching.
Key Stretching and Work Factors
Key stretching (also called key derivation) makes hashing deliberately slow by running the hash function many times or adding memory-intensive operations. This is the core defense mechanism of password hashing algorithms.
How key stretching works: Instead of computing one hash, the algorithm computes thousands or millions of iterations:
- bcrypt: Configurable cost factor (2^cost iterations; cost=12 → 4,096 iterations)
- scrypt: Configurable CPU and memory parameters
- Argon2: Configurable time, memory, and parallelism parameters
The effect on attack speed:
| Algorithm | Hashes/sec (per GPU) | Time to crack 8-char password | |-----------|---------------------|------------------------------| | MD5 | 40,000,000,000 | ~3 minutes | | SHA-256 | 5,000,000,000 | ~25 minutes | | bcrypt (cost=12) | ~50,000 | ~55 years | | Argon2id (tuned) | ~1,000 | ~2,700 years |
Key stretching transforms password cracking from "trivial" to "infeasible" for reasonable passwords.
Choosing the right work factor: The work factor should be tuned so that hashing takes approximately 250ms to 1 second on your server hardware. This is imperceptible to legitimate users logging in, but devastating to attackers who must compute millions of hashes:
- 250ms × 1 million guesses = 250,000 seconds (~3 days) per password
- 250ms for a user logging in = barely noticeable
You should increase the work factor over time as hardware gets faster. What takes 250ms today may take 50ms in five years.
Algorithm Comparison: Argon2 vs bcrypt vs scrypt
Argon2id (Recommended — the gold standard):
- Winner of the 2015 Password Hashing Competition
- Memory-hard: Requires significant RAM alongside CPU, making GPU/ASIC attacks expensive
- Argon2id variant combines Argon2i (side-channel resistant) and Argon2d (GPU resistant) — best of both worlds
- Three tunable parameters: time (iterations), memory (RAM required), parallelism (threads)
- Recommended by OWASP as the first choice for password hashing
bcrypt (Strong — widely supported):
- Introduced in 1999, battle-tested for 27 years
- CPU-intensive but not memory-hard (vulnerable to FPGA/ASIC attacks, though expensive)
- Single tunable parameter: cost factor (logarithmic — each increment doubles the work)
- Maximum password length of 72 bytes (truncates longer passwords)
- Available in virtually every programming language and platform
- Recommended when Argon2 is not available
scrypt (Strong — memory-hard):
- Designed specifically for GPU resistance through memory hardness
- Three tunable parameters: N (CPU/memory cost), r (block size), p (parallelism)
- Less studied than bcrypt, more complex to configure correctly
- Used extensively in cryptocurrency mining, which has produced specialized hardware (controversial for password hashing)
- Alternative when Argon2 is not available and memory hardness is important
PBKDF2 (Acceptable — weakest modern option):
- NIST-recommended (included in many compliance frameworks)
- Not memory-hard — purely CPU-based
- Requires very high iteration counts (600,000+ recommended by OWASP for SHA-256)
- Use only when compliance requires NIST-approved algorithms and Argon2 isn't accepted
Our recommendation: Use Argon2id for new applications. Use bcrypt for maximum compatibility. Avoid MD5, SHA-1, SHA-256, and PBKDF2 with low iterations.
Implementing Argon2id
Node.js (using argon2 package):
import argon2 from 'argon2';
// Hash a password
async function hashPassword(password) {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
// Verify a password
async function verifyPassword(hash, password) {
return await argon2.verify(hash, password);
}
Python (using argon2-cffi):
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4,
)
# Hash
hash = ph.hash("user_password")
# Verify
try:
ph.verify(hash, "user_password") # Returns True
except argon2.exceptions.VerifyMismatchError:
pass # Wrong password
Recommended Argon2id parameters (OWASP 2024):
- Memory: 64 MB (65536 KiB) minimum. Use more if server resources allow.
- Time: 3 iterations minimum. Increase until hashing takes ~250ms-1s on your hardware.
- Parallelism: Match the number of CPU cores available for hashing (typically 2-4).
Tuning process:
- Start with memory=64MB, time=3, parallelism=4
- Measure the hashing time on your production hardware
- If under 250ms, increase time or memory
- If over 1 second, decrease (though 1s is still acceptable for login)
- Benchmark under load to ensure the server can handle concurrent authentication requests
Implementing bcrypt
Node.js (using bcrypt):
import bcrypt from 'bcrypt';
// Hash a password (cost factor 12)
async function hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
// Verify a password
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
Python (using bcrypt):
import bcrypt
# Hash
password = b"user_password"
salt = bcrypt.gensalt(rounds=12)
hash = bcrypt.hashpw(password, salt)
# Verify
bcrypt.checkpw(password, hash) # Returns True/False
bcrypt cost factor recommendations:
- Minimum: 10 (for development/testing)
- Recommended: 12 (good balance for most servers in 2026)
- High security: 14+ (if server resources allow)
Important bcrypt limitations:
- 72-byte limit: bcrypt truncates passwords longer than 72 bytes. A 100-character password and the same password truncated to 72 bytes produce the same hash. Mitigation: pre-hash the password with SHA-256 before bcrypt (bcrypt(SHA-256(password)))
- $2a$ vs $2b$ prefixes: Use the latest bcrypt implementation for your language, which should use
$2b$
Common Password Storage Mistakes
Mistake 1: Using MD5, SHA-1, or SHA-256 for password hashing. These are general-purpose hash functions, not password hashing functions. They're designed for speed — exactly what you don't want for password hashing. Use Argon2id or bcrypt instead.
Mistake 2: Not using a salt (or using a global salt). A single salt for all users means identical passwords still produce identical hashes. Each user must have a unique, random salt. Modern password hashing libraries generate unique salts automatically.
Mistake 3: Encrypting instead of hashing. Encryption is reversible — if the key is compromised, all passwords are recovered. Hashing is one-way. For password storage, always hash, never encrypt.
Mistake 4: Hardcoding the work factor too low. A cost factor of 4 (bcrypt) or 1 iteration (Argon2) provides almost no protection. Benchmark on your hardware and configure for 250ms-1s.
Mistake 5: Rolling your own hashing scheme. "Hash it with SHA-256, then reverse the string, then hash again, then XOR with a secret" — custom schemes are almost always weaker than standard algorithms. Use established libraries that implement Argon2id, bcrypt, or scrypt correctly.
Mistake 6: Not handling password length properly. Some implementations silently truncate passwords (bcrypt at 72 bytes). Users creating a 30-word passphrase may lose most of it. Pre-hash with SHA-256 or use Argon2 (no length limit).
Mistake 7: Storing the algorithm/parameters separately from the hash.
Modern hashing functions embed the algorithm, parameters, and salt in the hash string itself (e.g., $argon2id$v=19$m=65536,t=3,p=4$salt$hash). Store this single string — don't try to parse and store components separately.
Migrating from Weak Hashing
If your application currently uses weak hashing (MD5, SHA-1, unsalted SHA-256), you need to migrate to a modern algorithm. Here's the safest approach:
Strategy 1: Wrap-and-upgrade (no user interaction required)
- For each existing MD5 hash, compute:
bcrypt(md5_hash)and store it as the new hash - Mark the account as "wrapped" (so you know it's bcrypt-of-MD5)
- When the user logs in with their password:
a. Compute
md5(password), thenbcrypt(md5_result)b. Verify against the stored hash c. If successful, re-hash withbcrypt(password)(dropping the MD5 layer) d. Update the stored hash and remove the "wrapped" flag
Benefits: All accounts are immediately upgraded to bcrypt-wrapped hashing without any user action. Over time, as users log in, accounts are upgraded to pure bcrypt/Argon2.
Strategy 2: Force password reset
- Invalidate all existing password hashes
- Require all users to reset their passwords via email
- New passwords are hashed with Argon2id/bcrypt
Drawback: User friction. Many users may not complete the reset, effectively losing access.
Strategy 3: Opportunistic upgrade
- Keep old hashes but add support for multiple hash formats
- When a user logs in, verify against the old hash format
- If successful, re-hash with the new algorithm and update storage
- After a grace period, force reset for accounts that haven't logged in
Our recommendation: Strategy 1 (wrap-and-upgrade) provides the best balance of security and user experience. It immediately strengthens all hashes and transparently upgrades individual accounts over time.
Use our password generator to create strong passwords and our strength checker to verify they'll resist cracking even if hash theft occurs.
Password hashing is one of the few areas in software development where the correct implementation is well-established and non-negotiable: use Argon2id or bcrypt with appropriate work factors, unique per-user salts (auto-generated by the library), and never store passwords in plaintext, encrypted form, or with fast hash algorithms. The difference between your next breach being an inconvenience or a catastrophe is entirely determined by this choice.