Security • ~11 min read
Salting and Hashing: Password Security Best Practices
Storing passwords securely is critical for protecting user accounts. This guide covers cryptographic hashing, salt generation, rainbow table attacks, and modern algorithms like bcrypt and Argon2 for bulletproof password storage.
Why hash passwords?
Storing passwords in plaintext is a catastrophic security failure. When a database is breached, attackers gain instant access to all user accounts. Hashing transforms passwords into irreversible digests, so even if your database is compromised, the original passwords remain protected.
High-profile breaches (LinkedIn 2012, Adobe 2013, Yahoo 2013-2014) exposed millions of inadequately protected passwords. Proper hashing and salting would have prevented mass credential theft.
What is hashing?
A cryptographic hash function takes an input (password) and produces a fixed-size output (hash/digest). Hash functions are designed to be:
- Deterministic: Same input always produces same output.
- One-way: Computationally infeasible to reverse (find input from hash).
- Avalanche effect: Tiny input change produces drastically different hash.
- Collision-resistant: Hard to find two inputs with same hash.
- Fast to compute: But not too fast (for password hashing).
Example
Input: "password123"
SHA-256: "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
Input: "password124"
SHA-256: "8bb6118f8fd6935ad0876a3be34a717d32708ffd27258c28e26e0a03f7e55c12"
Note how a single character change produces a completely different hash.
Rainbow table attacks
A rainbow table is a precomputed database mapping common passwords to their hashes. Attackers can instantly "crack" hashed passwords by looking them up in the table.
How the attack works
- Attacker precomputes hashes for millions of common passwords.
- Database breach exposes password hashes.
- Attacker looks up each hash in their rainbow table.
- Match found = password cracked in milliseconds.
Example scenario
Database stores: "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
Rainbow table lookup:
ef92b778... → "password123" ✓
Without additional defenses, unsalted hashes provide minimal protection against determined attackers with rainbow tables.
Salt: the defense
A salt is a unique, random string added to each password before hashing. This makes every hash unique, even for identical passwords, completely defeating rainbow table attacks.
How salting works
- Generate cryptographically secure random salt (16+ bytes).
- Combine salt + password.
- Hash the combined string.
- Store salt + hash together in database (salt is not secret).
Example with salt
User 1:
Password: "password123"
Salt: "a4f8d2e1c9b7"
Hash(salt + password): "3d5e7a1b..."
User 2 (same password!):
Password: "password123"
Salt: "x7k2m9n4p1q8"
Hash(salt + password): "9f2c4e6a..."
Same password → different salts → completely different hashes. Rainbow tables become useless because attackers must recompute hashes for every salt.
Why salts work
- Each password has unique hash, preventing batch lookups.
- Attackers must crack each password individually.
- Rainbow tables would need to be regenerated for each salt (infeasible).
- Salts can be stored in plaintext alongside hashes (they're not secret).
Hash functions compared
Fast hash functions (DO NOT use for passwords)
- MD5: Cryptographically broken. Vulnerable to collisions. Never use.
- SHA-1: Deprecated. Collision attacks demonstrated. Avoid.
- SHA-256/SHA-512: Secure but too fast (~1 billion hashes/sec on GPU). Enables brute force attacks. Not designed for passwords.
These algorithms are excellent for file integrity verification but terrible for password storage because they're optimized for speed.
Slow hash functions (Password-specific)
- bcrypt: Deliberately slow, configurable work factor. Industry standard for 20+ years. Uses Blowfish cipher. Limited to 72-character passwords.
- scrypt: Memory-hard algorithm resists GPU/ASIC attacks. Requires significant RAM to compute. Used by cryptocurrencies.
- Argon2: Winner of 2015 Password Hashing Competition. Three variants: Argon2i (side-channel resistant), Argon2d (GPU resistant), Argon2id (hybrid, recommended).
- PBKDF2: Older standard (NIST approved). Uses repeated hashing (iterations). Effective but bcrypt/Argon2 are better.
Modern password hashing
bcrypt
bcrypt remains the gold standard for most applications. Key features:
- Built-in salt generation (16 bytes)
- Adjustable cost factor (work factor: 2^cost rounds)
- Mature libraries in all major languages
- Industry-proven over decades
bcrypt output example:
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqJxN2.aSe
$2b$ = algorithm | $12$ = cost | salt + hash
Argon2
State-of-the-art password hashing. Recommended for new projects:
- Memory-hard: requires configurable RAM (defends against GPUs/ASICs)
- Time-cost: configurable iterations
- Parallelism: configurable threads
- Use Argon2id for best security
Argon2id output example:
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
Cost/work factor tuning
Password hashing should be slow enough to frustrate brute force attacks but fast enough not to degrade user experience:
- Target: 250-500ms per hash on your production hardware
- bcrypt: cost factor 12-14 (as of 2025)
- Argon2: 64-128 MB memory, 3-4 iterations
- Increase cost factor annually as hardware improves
Implementation guide
Registration flow
- User submits password over HTTPS.
- Server validates password strength (length, complexity).
- Generate salt and hash password (bcrypt/Argon2).
- Store username + salt + hash in database.
- Discard plaintext password immediately.
Login flow
- User submits credentials over HTTPS.
- Retrieve stored salt + hash for that username.
- Hash submitted password with stored salt.
- Compare computed hash with stored hash (constant-time comparison).
- Grant/deny access based on match.
Code example (Node.js + bcrypt)
const bcrypt = require('bcrypt');
// Registration
async function hashPassword(password) {
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
return hash; // Store this in database
}
// Login
async function verifyPassword(password, storedHash) {
const match = await bcrypt.compare(password, storedHash);
return match; // true if valid
}Common mistakes
- Using fast hashes: SHA-256 alone is not suitable for passwords.
- No salt: Makes rainbow table attacks trivial.
- Reusing salts: Defeats the purpose of salting.
- Keeping salts secret: Salts should be random, not secret. Storing them with hashes is fine.
- Rolling your own crypto: Use established libraries (bcrypt, Argon2).
- Not using constant-time comparison: Vulnerable to timing attacks.
- Pepper instead of proper algorithms: Adding a secret "pepper" to SHA-256 doesn't make it suitable for passwords.
- Client-side hashing only: Server must hash; client hashing alone is insecure.
Best practices
Algorithm selection
- New projects: Use Argon2id.
- Existing projects: bcrypt is still excellent.
- Avoid: MD5, SHA-1, plain SHA-256, homegrown solutions.
Configuration
- Salt: minimum 16 bytes, cryptographically random.
- bcrypt cost: 12-14 (2025 standard).
- Argon2: 64-128 MB memory, 3-4 iterations, 4 threads.
- Test on your hardware; target 250-500ms per hash.
Security measures
- Always transmit passwords over HTTPS/TLS.
- Rate limit login attempts (prevent brute force).
- Implement account lockout after repeated failures.
- Use constant-time comparison to prevent timing attacks.
- Log authentication events for security monitoring.
- Never log plaintext passwords.
Ongoing maintenance
- Rehash passwords on login if cost factor is outdated.
- Monitor algorithm recommendations (e.g., OWASP guidelines).
- Keep cryptographic libraries updated.
- Enforce strong password policies (length, complexity).
- Consider multi-factor authentication (MFA) as additional layer.