Password security seems simple—just hash and store, right? Unfortunately, many developers get it wrong, leading to catastrophic breaches. Here's 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
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!