Password hashing and secure storage

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.

Beginner

5 min read

The plaintext mistake

Storing passwords as plaintext:

# NEVER DO THIS
users_table = {
    'alice@example.com': 'password123',
    'bob@example.com': 'mySecretPass'
}

Problems:

  • If the database leaks, every password is compromised immediately.
  • Database admins (and anyone with database access) can read every password.
  • If alice reuses passwords across services, every service is compromised.

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.

One-way functions: hashing

A hash function takes input and produces output:

hash("password123") = "$2b$12$R9h7cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ee..."

Key properties:

  • One-way: given the hash, you can't recover the original password.
  • Deterministic: the same password always produces the same hash.
  • Fast to compute (but this is actually a problem for passwords, see "salts" below).
  • Sensitive to small changes: changing one character in the password produces a completely different hash.

Login flow with hashing

  1. User signs up, provides password.

  2. Backend hashes the password: hash("password123")

  3. Backend stores the hash (not the password): db.save(email, hash_result)

  4. User logs in, provides password.

  5. Backend hashes the password provided: hash("password123")

  6. Backend compares: does this hash match the stored hash?

  7. 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 401

Salts: protecting against rainbow tables

An 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 salt

Cost factor: slowing down brute force

Simple 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.

Modern hashing algorithms

Don't use: MD5, SHA-1, SHA-256 (designed for data integrity, not passwords).

Do use:

  • bcrypt: battle-tested, automatic salt + cost factor, excellent for passwords.
  • Argon2: newer, flexible (time/memory trade-offs), memory-hard (resistant to GPU-accelerated attacks).
  • PBKDF2: acceptable if bcrypt/Argon2 aren't available, but requires more configuration.
# 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 THIS

Password reset: a different problem

Hashing solves storage. But what if alice forgets her password? You can't read the hash to remind her.

Password reset typically works like this:

  1. Alice clicks "Forgot Password."
  2. Backend generates a random token (long, secure, unguessable): token = generate_secure_token(32)
  3. Backend stores the token temporarily (valid for 15 minutes): db.save_reset_token(alice_email, token, expires_at=now + 15min)
  4. Backend emails alice a link: https://myapp.com/reset-password?token=<token>
  5. Alice clicks the link. Frontend shows a "Set New Password" form.
  6. Alice enters a new password.
  7. Backend validates the token hasn't expired, then sets a new password hash (same bcrypt process).
  8. Backend deletes the reset 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.

Common mistakes

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 True

Timing 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.

Further reading

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?