Never store passwords in plaintext. Hash them with a one-way function (bcrypt, Argon2) so that even you can't read them. Use salts and high computational cost to slow down brute-force attacks. If your database leaks, leaked hashes are useless.
5 min read
Storing passwords as plaintext:
# NEVER DO THIS
users_table = {
'alice@example.com': 'password123',
'bob@example.com': 'mySecretPass'
}Problems:
Instead: hash passwords. Hashing is one-way — you can turn a password into a hash, but you can't turn a hash back into a password.
A hash function takes input and produces output:
hash("password123") = "$2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee..."
Key properties:
User signs up, provides password.
Backend hashes the password: hash("password123")
Backend stores the hash (not the password): db.save(email, hash_result)
User logs in, provides password.
Backend hashes the password provided: hash("password123")
Backend compares: does this hash match the stored hash?
If yes, they're logged in. If no, invalid password.
import bcrypt
# Sign up
password = request.form['password']
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
db.save_user(email, hashed)
# Login
provided_password = request.form['password']
stored_hash = db.get_user_hash(email)
if bcrypt.checkpw(provided_password.encode(), stored_hash):
# Password matches
issue_session_token(email)
else:
# Invalid password
return 401An attacker might precompute hashes for common passwords:
"password": 5f4dcc3b5aa765d61d8327deb882cf99
"123456": e10adc3949ba59abbe56e057f20f883e
"qwerty": d41d8cd98f00b204e9800998ecf8427e
...
This is a "rainbow table." To crack passwords, they look up the hash: "if the leaked hash matches this entry, the password is qwerty."
Salt stops this: add a random string to the password before hashing:
hash("password123" + "xK3n2L") → different hash
hash("password123" + "pQ8vJ2") → different hash
The same password hashed with different salts produces different hashes. Rainbow tables are useless.
Bcrypt automatically handles salts:
# Bcrypt includes the salt in the output
# Format: $2b$12$<salt><hash>
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
# Result: b'$2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee...'
# When checking, bcrypt extracts the salt and uses it
bcrypt.checkpw(provided.encode(), stored_hash) # automatically uses the stored saltSimple hash functions are fast. An attacker can try millions of passwords per second:
test "password123" → hash → check against leaked_hash
test "password124" → hash → check against leaked_hash
... millions more per second
Cost factor (work factor) makes hashing intentionally slow. Bcrypt's cost factor is a tunable parameter:
# cost_factor = 12 means: run the hashing algorithm 2^12 = 4096 times
bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))With cost 12, hashing one password takes ~100ms. Trying a million passwords takes ~27 hours. At cost 14, it takes ~270 hours. This is intentional — it makes brute-forcing expensive, while login speed (one hash per login) remains acceptable.
Over time, computers get faster, so increase the cost factor over time. If bcrypt cost 12 was safe in 2020, increase it to 14 by 2025.
Don't use: MD5, SHA-1, SHA-256 (designed for data integrity, not passwords).
Do use:
# Bcrypt (recommended)
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Argon2 (also excellent)
from argon2 import PasswordHasher
hasher = PasswordHasher()
hashed = hasher.hash(password)
# NOT for passwords (even though some people use it)
import hashlib
hashed = hashlib.sha256(password.encode()).hexdigest() # DON'T DO THISHashing solves storage. But what if alice forgets her password? You can't read the hash to remind her.
Password reset typically works like this:
token = generate_secure_token(32)db.save_reset_token(alice_email, token, expires_at=now + 15min)https://myapp.com/reset-password?token=<token>Important: the reset link should not contain the new password or any hint about it. The token proves alice controls the email; the new password is something only she chooses.
Storing passwords with weak hashing
# WRONG
hashed = hashlib.md5(password.encode()).hexdigest()
# CORRECT
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))Comparing hashes with ==
# WRONG — timing attacks
if computed_hash == stored_hash:
return True
# CORRECT — constant-time comparison
import hmac
if hmac.compare_digest(computed_hash, stored_hash):
return TrueTiming attacks measure how long a comparison takes. If comparison stops early on mismatch, an attacker can guess characters one by one based on timing. Use constant-time comparison.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why is hashing passwords one-way?
2. What is the purpose of a salt in password hashing?
3. What does cost factor (work factor) do in bcrypt?
4. Which algorithm is NOT recommended for password hashing?
Auth, Authorization & Multi-Tenancy