How to Hash Passwords Instead of Storing Them in Plaintext
Storing passwords in plaintext means one database breach hands an attacker every account immediately. The fix is to hash on registration and verify the hash on login - never compare raw strings. Here's how to do it correctly in a Flask/PostgreSQL app.
Why plaintext passwords are catastrophic
When you store a raw password string in a users table, any attacker who reads
the database - via SQL injection, a backup leak, or a compromised admin - gets
every user's password instantly. Worse, most users reuse passwords, so that breach
propagates to their email, bank, and every other site.
The audit finding looks like this:
SELECT username, password FROM users;
-- username | password
-- ----------+----------
-- alice | hunter2
-- bob | Password1
That's game over. Hashing makes a breach an inconvenience rather than a catastrophe.
Hash on register, verify on login
Use werkzeug.security - it wraps PBKDF2-HMAC-SHA256 with a per-user salt,
which means two users with the same password get different stored strings.
from werkzeug.security import generate_password_hash, check_password_hash
# /register endpoint - hash before inserting
hashed = generate_password_hash(password) # e.g. "pbkdf2:sha256:600000$..."
cur.execute(
"INSERT INTO users (username, email, password) VALUES (%s, %s, %s)",
(username, email, hashed),
)
On login, fetch the row by username only - never put the password in the WHERE
clause - then verify in Python:
# /login endpoint - fetch by username, verify hash in Python
cur.execute(
"SELECT id, username, password FROM users WHERE username = %s",
(username,),
)
user = cur.fetchone()
if user and check_password_hash(user[2], password):
return jsonify({"message": "Login successful", "user_id": user[0]})
else:
return jsonify({"error": "Invalid credentials"}), 401
check_password_hash does a constant-time comparison, which prevents timing
attacks. Never use == to compare hashes.
Why the login query changes too
The vulnerable pattern is:
SELECT id FROM users WHERE username = %s AND password = %s
This pushes the comparison into SQL, which has two problems: it breaks the
moment you hash (the hash won't match the raw input), and it leaks timing
information to anyone measuring query duration. Fetch by username, then call
check_password_hash - the work stays in Python.
Production notes
werkzeug.securitydefaults to PBKDF2-SHA256 with 600,000 iterations - adequate for now, but modern guidance favors bcrypt or argon2id for new systems.passliborargon2-cffimake the switch straightforward.- Tune the work factor so verification takes roughly 250ms on your hardware. Too low and a GPU can brute-force a leaked table; too high and login latency hurts.
- The
passwordcolumn must be at least VARCHAR(256) to hold a PBKDF2 or bcrypt hash string - VARCHAR(500) is fine.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Calling generate_password_hash before inserting a new user row
- Fetching by username only in the login query, then verifying with check_password_hash
- Auditing an existing app for plaintext password storage and migrating safely
FAQ
Why is storing plaintext passwords dangerous?
Any attacker who reads the database - via SQL injection, a backup leak, or a compromised admin account - gets every user's password immediately. Because users reuse passwords, the breach spreads beyond your app to their email and other accounts.
How does generate_password_hash work?
werkzeug.security's generate_password_hash wraps PBKDF2-HMAC-SHA256 with a random per-user salt, then encodes the algorithm, iteration count, salt, and hash into a single string safe to store in a VARCHAR column. Each call produces a different string even for identical passwords.
Should I use bcrypt instead of werkzeug.security?
For new production systems, bcrypt or argon2id are the current recommendations because they are memory-hard and tunable. werkzeug.security's PBKDF2 is correct and safe, but bcrypt/argon2 are harder to attack with GPUs at high parallelism.
Is bcrypt safe for password hashing?
Yes. bcrypt is a deliberately slow, salted hash built for passwords - the cost factor makes brute force expensive and the per-password salt defeats rainbow tables. It is a solid choice alongside argon2 and scrypt; never store plain or fast-hashed (MD5/SHA-256) passwords.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →