User passwords must never be stored as plaintext, or hashed with plain SHA-256, in the database. Werkzeug — the library that ships with Flask — already provides two functions, generate_password_hash() and check_password_hash(), that use the scrypt algorithm with a random salt — secure enough for most Flask projects without installing anything extra. This post shows you how to use them correctly in 5 minutes.

The Problem

Many beginner Flask tutorials still store passwords as plaintext, or “improve” things with hashlib.sha256(password.encode()).hexdigest(). Both are dangerous: a database leak exposes every password, and SHA-256 has no salt, so the same password always produces the same hash — an attacker can look it up in a rainbow table or try billions of common passwords in seconds. A weak password like “123456” is guessed instantly even after hashing.

# WRONG: storing plaintext in the database
user = User(username="linh", password="MatKhau123")
db.session.add(user)
# ALSO WRONG: SHA-256 without salt
import hashlib
pw_hash = hashlib.sha256("MatKhau123".encode()).hexdigest()

The Solution

Werkzeug already ships with Flask, so you need to install nothing. generate_password_hash() uses scrypt by default with a random 16-byte salt; hashing the same password twice gives a different result each time, which makes rainbow tables useless. check_password_hash() compares with hmac.compare_digest — a constant-time comparison that resists timing attacks.

from werkzeug.security import generate_password_hash, check_password_hash
# On registration: store only the hash, never the original password
user = User(
username="linh",
password_hash=generate_password_hash("MatKhauManh!2026"),
)
db.session.add(user)
db.session.commit()
# On login: compare the entered password against the stored hash
user = User.query.filter_by(username="linh").first()
if user and check_password_hash(user.password_hash, form.password.data):
login_user(user)
else:
flash("Sai ten dang nhap hoac mat khau")

Scrypt is designed to be memory-hungry, so even an attacker with a GPU struggles to brute-force at scale; the random salt makes every hash unique. If your project needs a different algorithm, pass method="pbkdf2" — old hashes still verify, because the algorithm name and parameters live inside the hash string itself.

Apply It to Your Project

Rename the password column to password_hash (a String of at least 255 characters, since a scrypt hash is about 150 characters long), rewrite the registration and login routes to use the two functions above, then run the migration. For old users whose passwords are stored as plaintext or SHA-256, don’t try to “convert” the old hashes — that is impossible without the original password. The right approach is to make them reset their password at the next login, then store the new hash with Werkzeug.

Related Posts

Frequently Asked Questions

Which hashing algorithm does Werkzeug use by default?

Scrypt with parameters n=32768, r=8, p=1 and a random 16-byte salt (since Werkzeug 3.x). You can switch to PBKDF2 with generate_password_hash(pw, method="pbkdf2").

Does the scrypt hash make login too slow?

Not noticeably for real users: each verification costs a few dozen milliseconds. That is small enough that nobody notices, but expensive enough to make bulk brute-forcing impractical.

Do old hashes still work after changing algorithms?

Yes. The hash string carries its own algorithm name and parameters, so check_password_hash() recognizes them and verifies correctly — no data migration needed.

Do I need to store the salt separately in the database?

No. The salt lives inside the hash string that generate_password_hash() returns, so a single password_hash column is enough.

Does check_password_hash() protect against timing attacks?

Yes. It compares with hmac.compare_digest, which runs in constant time no matter which character of the password is wrong, so an attacker cannot guess the password from response times.

References