Password hashing and salting work by running each password through a deliberately slow, one-way function together with a unique random salt, so the value you store can't be reversed or matched against a precomputed table. It sounds simple—just hash and store, right? Unfortunately, many developers get it wrong, leading to catastrophic breaches. Here's how it actually works, and how to do it properly.
Why You Should Never Store Plain Passwords
When a database is breached (and breaches happen to everyone), plain-text passwords mean:
- Attackers get immediate access to user accounts
- Users who reuse passwords are compromised everywhere
- Your company faces legal liability and reputation damage
-- NEVER DO THIS
INSERT INTO users (email, password) VALUES ('user@example.com', 'MyPassword123');
Even encrypted passwords are risky—if attackers get the encryption key, all passwords are exposed at once.
Why Encryption Isn't the Answer
"Why not encrypt passwords instead of hashing?"
Encryption is reversible—with the key, you can recover the original password. This creates risks:
- Key management complexity
- Single point of failure (compromised key = all passwords)
- Insider threats (anyone with key access)
- You don't need to recover passwords—users can reset them
Hashing is one-way. Even you can't recover the original password, which is exactly what we want.
Hashing Passwords: The Basics
A hash function converts a password to a fixed-length string:
hash("MyPassword123") → "a1b2c3d4e5f6..."
To verify: hash the entered password and compare with stored hash.
def verify_password(entered, stored_hash):
return hash(entered) == stored_hash
But simple hashing has a fatal flaw...
The Rainbow Table Problem
A rainbow table is a precomputed database of password → hash mappings:
password → 5f4dcc3b5aa765d61d8327deb882cf99
123456 → e10adc3949ba59abbe56e057f20f883e
qwerty → d8578edf8458ce06fbc5bb76a58c5ca4
...millions more...
If you hash passwords with MD5 or SHA:
- Attackers download a rainbow table
- Look up each hash in your breached database
- Instantly recover common passwords
Salting Explained
A salt is random data added to each password before hashing:
salt = generate_random_bytes(16) # Different for each user
hash = sha256(salt + password)
store(username, salt, hash)
Now the same password produces different hashes:
| User | Password | Salt | Hash |
|---|---|---|---|
| Alice | password123 | a3f8... | 9e2d1f... |
| Bob | password123 | 7b2c... | 5a8c3e... |
Rainbow tables become useless—attackers would need a table for every possible salt.
Salt Requirements
- Unique per user: Never reuse salts
- Random: Use cryptographic random generator
- Long enough: At least 16 bytes (128 bits)
- Stored with hash: You need it to verify
Modern Password Hashing: bcrypt, Argon2, scrypt
Standard hash functions (MD5, SHA) are designed to be fast. For passwords, that's a problem—attackers can try billions of guesses per second.
Password hashing algorithms are intentionally slow:
bcrypt
The veteran choice, battle-tested since 1999:
import bcrypt
# Hash a password
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
# b'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/A.1JYTfsO/zL.xVZ.'
# Verify
if bcrypt.checkpw(password.encode(), hashed):
print("Valid!")
The output contains the salt and cost factor—all in one string.
Work factor: The 12 in $2b$12$... is the cost factor. Each increment doubles the computation time. Adjust based on your server capacity.
Argon2
Winner of the Password Hashing Competition (2015). Resists GPU attacks better than bcrypt:
from argon2 import PasswordHasher
ph = PasswordHasher()
hash = ph.hash("password123")
# $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...
try:
ph.verify(hash, "password123")
print("Valid!")
except:
print("Invalid!")
Argon2 has three variants:
- Argon2d: Fast, but vulnerable to side-channel attacks
- Argon2i: Resistant to side-channel attacks
- Argon2id: Hybrid, recommended for passwords
scrypt
Memory-hard function, making parallel attacks expensive:
const crypto = require('crypto');
const hash = crypto.scryptSync(password, salt, 64);
Which Should You Choose?
| Algorithm | Recommendation |
|---|---|
| Argon2id | Best choice for new systems |
| bcrypt | Excellent, well-understood |
| scrypt | Good, but Argon2 is preferred |
| SHA-256 | ❌ Not for passwords |
| MD5 | ❌ Never |
Common Password Mistakes
1. Using fast hashes
# WRONG
password_hash = hashlib.sha256(password.encode()).hexdigest()
# RIGHT
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
2. Not using salt
# WRONG
hash = sha256(password)
# RIGHT (bcrypt includes salt automatically)
hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
3. Reusing salts
# WRONG
global_salt = "mysalt"
hash = sha256(global_salt + password)
# RIGHT
salt = generate_unique_random_salt()
hash = sha256(salt + password)
4. Low work factor
# WRONG - too fast
bcrypt.hashpw(password, bcrypt.gensalt(rounds=4))
# RIGHT - adjust based on your needs
bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
5. Truncating passwords
Some systems silently truncate long passwords:
# bcrypt truncates at 72 bytes
# Pre-hash long passwords:
if len(password) > 72:
password = sha256(password).digest()
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
Password Strength Factors
Help users create strong passwords:
Length over complexity
correcthorsebatterystaple is stronger than P@ssw0rd!
Check against breached passwords
Use the Have I Been Pwned API to reject compromised passwords.
Allow long passwords
Support at least 64 characters (ideally unlimited).
Don't require arbitrary rules
Requirements like "must contain uppercase, number, symbol" lead to predictable patterns like Password1!
Implementing Secure Authentication
Complete example (Python/Flask)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
def create_user(username, password):
# Validate password strength first
if len(password) < 12:
raise ValueError("Password too short")
# Check against breached passwords (HIBP API)
if is_password_breached(password):
raise ValueError("Password found in data breach")
# Hash and store
hashed = ph.hash(password)
db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
[username, hashed]
)
def verify_user(username, password):
user = db.get_user(username)
if not user:
# Prevent timing attacks - still do hash comparison
ph.hash(password) # Dummy hash
return False
try:
ph.verify(user.password_hash, password)
# Check if rehash needed (algorithm updated)
if ph.check_needs_rehash(user.password_hash):
new_hash = ph.hash(password)
db.update_password_hash(username, new_hash)
return True
except VerifyMismatchError:
return False
Frequently Asked Questions
How does password hashing work?
Hashing runs a password through a one-way function that always produces the same fixed-length output for the same input, but can't be reversed to recover the original. When a user signs up, you store the hash instead of the password. When they log in, you hash what they typed and compare it to the stored hash—if they match, the password is correct. Because the function only goes one way, a stolen database of hashes doesn't hand attackers the passwords directly. The catch: you must use a slow, password-specific hash (bcrypt, Argon2, or scrypt), not a fast general-purpose one.
What is salting a password?
A salt is a unique, random value added to each password before it's hashed. Without it, two users with the same password get the same hash, and attackers can crack many accounts at once using precomputed "rainbow tables." With a per-user salt, identical passwords produce completely different hashes, so those precomputed tables become useless and each password has to be attacked individually. The salt isn't secret—it's stored alongside the hash and is needed to verify the password later.
Is SHA-256 good for hashing passwords?
No. SHA-256 is a great general-purpose hash, but it's the wrong tool for passwords because it's fast—modern hardware can compute billions of SHA-256 hashes per second, which is exactly what an attacker wants when brute-forcing a stolen database. Password storage needs a deliberately slow, tunable algorithm like bcrypt or Argon2 that makes each guess expensive. Use SHA-256 for integrity checks and signatures, not for storing passwords.
bcrypt vs Argon2 — which should I use?
For a brand-new system, prefer Argon2id: it won the 2015 Password Hashing Competition and is memory-hard, which resists GPU and ASIC cracking better than bcrypt. That said, bcrypt is an excellent, battle-tested choice that's been in production since 1999 and is available in virtually every language—if your framework already ships it and it's tuned to a sensible work factor, there's no urgent reason to switch. Both are far better than any fast hash. The short version: choose Argon2id if you can, bcrypt if that's what you have.
Why not just encrypt passwords instead of hashing them?
Encryption is reversible—anyone with the key can turn the stored value back into the original password, which makes the key a single point of catastrophic failure and creates insider-threat and key-management risks. Hashing is one-way: even you can't recover the original, and you don't need to. Users prove their identity by re-hashing what they type, and if they forget a password they reset it rather than "recovering" it. For password storage, that irreversibility is the whole point.
Summary
Password security requires:
- Never store plain text - Always hash
- Use password-specific algorithms - bcrypt, Argon2, or scrypt
- Unique salt per password - Built into modern algorithms
- Appropriate work factor - Balance security and performance
- Length over complexity - Encourage passphrases
- Check breached passwords - Reject known-compromised passwords
| Do | Don't |
|---|---|
| Use Argon2id or bcrypt | Use MD5, SHA-256 |
| Unique salt per user | Reuse salts |
| Allow long passwords | Truncate at 8 characters |
| Encourage passphrases | Require P@ssw0rd! patterns |
| Check breach databases | Accept any password |
Want to generate strong passwords? Try our Password Generator and Password Strength Checker!