Flask doesn’t ship its own auth system, so most projects lean on werkzeug.security’s generate_password_hash() and check_password_hash() and call it done. That’s usually the right call, but it hides just enough detail that small mistakes don’t show up until production, a dependency bump, or a support ticket from a user who “definitely” typed the right password.

Quick Answer
Most Flask password hashing bugs come down to one of three things: your database column is too short for the hash string Werkzeug now generates, your Werkzeug version defaults to a hashing method (scrypt) your Python/OpenSSL build doesn't support, or you're calling check_password_hash() with a None value because the user never set a local password. Check those three first before you assume the password itself is wrong.

Here are seven mistakes that show up over and over in Flask codebases, roughly ordered from “will break your app today” to “will quietly weaken your security for years.”

1. Your Database Column Is Too Short for the Hash

This is the single most common one, and it’s almost always self-inflicted by an old tutorial. A lot of Flask starter projects (including some very popular ones) define the password field like this:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    password_hash = db.Column(db.String(80))

That was roughly fine when Werkzeug’s default method was pbkdf2:sha256, which produces a string a little under 100 characters. It stopped being fine the moment Werkzeug 2.3 switched the default hashing method to scrypt, which produces a noticeably longer string. If your column is capped at 80 or even 128 characters, the database silently truncates the hash on insert (or your ORM raises a DataError, depending on your database engine and strict mode settings). Either way, check_password_hash() will never match again, because it’s comparing against a mangled string.

# Before: too narrow for modern Werkzeug hashes
password_hash = db.Column(db.String(80))

# After: give yourself real headroom
password_hash = db.Column(db.String(255))

There’s no real cost to over-allocating here. Use String(255) (or Text, if your database charges nothing extra for it) and never think about this again.

2. Hitting ValueError: Unsupported hash type scrypt

If you upgraded Werkzeug and suddenly see this on login or registration, it’s not a bug in your code — it’s an environment problem:

# The error you'll see:
ValueError: Unsupported hash type scrypt. Werkzeug uses OpenSSL for this
functionality, and your installation of OpenSSL does not support it.

Python’s hashlib.scrypt() depends on the OpenSSL version it was built against, and some environments — minimal Docker base images, certain Python builds linked against LibreSSL instead of OpenSSL, older CentOS/RHEL systems — don’t have scrypt support compiled in. Werkzeug 2.3+ defaults generate_password_hash() to scrypt, so any of those environments will blow up the first time someone registers or logs in.

You’ve got two reasonable fixes. Pin the hashing method explicitly if you can’t control the deployment environment:

from werkzeug.security import generate_password_hash

# Force a method that doesn't depend on OpenSSL's scrypt support
password_hash = generate_password_hash(password, method="pbkdf2:sha256")

Or fix the environment instead — for Docker, switch to a base image with a modern OpenSSL-linked Python (the standard python:3.12-slim images are fine; some -alpine variants have historically had gaps here depending on the musl/OpenSSL combination). Test this in your actual deployment target, not just your laptop, since scrypt support genuinely varies by build.

You can check for scrypt support directly before you ever get a confusing traceback in production:

import hashlib

try:
    hashlib.scrypt(b"test", salt=b"salt", n=2, r=8, p=1)
    print("scrypt is supported")
except ValueError as e:
    print(f"scrypt is NOT supported: {e}")

Run that inside the actual container image or server you deploy to, not just on your development machine — it’s exactly the kind of gap that differs between a Mac laptop and a slim Linux base image, which is why this bug so often shows up for the first time in staging or production.

3. Calling check_password_hash() on None

This one hits apps that support both local passwords and OAuth/SSO login. A user who signed up via Google never gets a password_hash set — it’s None in the database. If your login view doesn’t account for that:

# This crashes for OAuth-only users:
user = User.query.filter_by(email=email).first()
if user and check_password_hash(user.password_hash, submitted_password):
    login_user(user)
# The error you'll see:
AttributeError: 'NoneType' object has no attribute 'split'

check_password_hash() assumes it’s getting a real hash string and tries to parse it immediately — it doesn’t guard against None. Check for that case explicitly before calling it:

user = User.query.filter_by(email=email).first()
if user and user.password_hash and check_password_hash(user.password_hash, submitted_password):
    login_user(user)
else:
    flash("Invalid credentials, or this account uses a different sign-in method.")

That second branch is also a better user experience — “wrong password” is a confusing message for someone who never set one.

4. Never Rehashing Old, Weaker Hashes

Hashing parameters age. A pbkdf2:sha256 hash generated with the iteration count Werkzeug defaulted to five years ago is measurably weaker against modern hardware than one generated today, and nobody goes back and re-hashes every password in the database “just because.” The practical fix is to rehash lazily, at the one moment you already have the plaintext password in memory: login.

from werkzeug.security import check_password_hash, generate_password_hash

def verify_login(user, submitted_password):
    if not check_password_hash(user.password_hash, submitted_password):
        return False

    # Upgrade the stored hash in place if it was made with old parameters
    if needs_rehash(user.password_hash):
        user.password_hash = generate_password_hash(submitted_password)
        db.session.commit()

    return True

def needs_rehash(pwhash):
    # crude but effective: flag anything not using the current method
    return not pwhash.startswith("scrypt:")

This costs nothing extra for users who log in regularly, and it means your hash strength creeps upward over time instead of being frozen at whatever Werkzeug defaulted to on the day each account was created.

5. Comparing Passwords Directly Instead of Hashing at All

This sounds too basic to make the list, but it still turns up in real codebases — usually in an admin backdoor, a “temporary” debug route, or code inherited from a non-Flask prototype:

# Somewhere it really shouldn't be:
if user.password == request.form["password"]:
    login_user(user)

If user.password is plaintext, you’ve got a much bigger problem than a bug — every database backup, replica, and log line that touches that column is a credential leak waiting to happen. There’s no gradual fix here: hash every password at write time with generate_password_hash(), migrate existing plaintext values in a one-time script, and delete the column name password in favor of password_hash so it’s obvious at a glance that nothing should ever store a raw value there again.

6. Passing the Wrong Type to generate_password_hash()

generate_password_hash() expects a str, not bytes. This mostly bites people pulling credentials from somewhere other than a normal Flask form — a JSON API body that was manually decoded, a CLI script reading from stdin, or a value that went through an encoding step it didn’t need:

# Passing bytes instead of str:
raw = request.get_data()  # bytes
password_hash = generate_password_hash(raw)
# The error you'll see (varies by Werkzeug version, but similar to):
TypeError: startswith first arg must be str, not bytes

Decode explicitly at the boundary instead of hoping it works out:

data = request.get_json()
password_hash = generate_password_hash(data["password"])  # already a str from JSON

If you truly are working with raw bytes (say, from a non-JSON binary upload — which a password field shouldn’t be anyway), call .decode("utf-8") before it reaches generate_password_hash().

7. Swallowing Every Exception Around the Hash Check

It’s tempting to wrap the whole login flow in a broad try/except and treat any failure as “invalid credentials,” on the theory that you don’t want to leak information to an attacker. The problem is that this also hides real bugs — a None hash, a corrupted database row, an unsupported hash type — behind a message that looks identical to a simple typo.

# This treats every possible failure identically:
try:
    if check_password_hash(user.password_hash, submitted_password):
        login_user(user)
except Exception:
    flash("Invalid credentials.")

Catch narrowly, and log what actually happened even if you still show the user a generic message:

try:
    valid = user.password_hash and check_password_hash(user.password_hash, submitted_password)
except ValueError:
    app.logger.exception("Password hash check failed for user %s", user.id)
    valid = False

if valid:
    login_user(user)
else:
    flash("Invalid credentials.")

That distinction matters in practice: “wrong password” is a support non-issue, but “every login is failing because of an unsupported hash type in production” is an incident. A blanket except Exception makes them look identical in your logs until someone escalates.

Bonus Tip: Don’t Reinvent This If You Don’t Have To

If your app needs more than Werkzeug’s defaults offer — algorithm agility, deprecation policies, or hashing for things beyond login passwords — look at Passlib or argon2-cffi before writing your own wrapper around hashlib. Werkzeug’s helpers are good enough for the vast majority of Flask apps, but “good enough” stops being true the moment you need to support multiple hash formats during a migration or meet a specific compliance requirement (PCI-DSS, SOC 2, etc.) that names an approved algorithm list.

If you’re also dealing with session or login-state bugs alongside hashing issues, our Flask-Login current_user guide covers the other half of most auth bugs. And if CSRF errors are showing up on the same login form, the Flask CSRF token invalid fix walks through that separately — it’s easy to conflate the two when a login form breaks all at once.

Testing These Fixes Before They Reach Production

Most of these mistakes are cheap to catch with a single test that exercises the real functions instead of mocking them out. A quick smoke test in your suite will surface a truncated column or an unsupported hash method long before a real user hits it:

def test_password_hash_roundtrip(app):
    from werkzeug.security import generate_password_hash, check_password_hash

    pwhash = generate_password_hash("correct-horse-battery-staple")
    assert len(pwhash) <= 255  # matches your actual column length
    assert check_password_hash(pwhash, "correct-horse-battery-staple")
    assert not check_password_hash(pwhash, "wrong-password")

Run that in CI against the same base image (or as close to it as you can get) that you deploy to, and mistakes #1 and #2 stop being production incidents and start being a failed test on a pull request instead.

Wrapping Up

None of these seven mistakes are exotic — they’re the kind of thing that passes code review because the code “looks right” and only breaks under a specific database engine, deployment image, or account type you didn’t test locally. Widen your hash column now, pin your hashing method deliberately instead of trusting the default forever, and guard check_password_hash() against None. Do those three and you’ll avoid the overwhelming majority of “why did login just stop working” incidents tied to password hashing.

When one of these does throw a real traceback, use Debugly’s trace formatter to quickly parse and analyze the Python traceback and jump straight to the line that’s actually failing instead of scrolling through Werkzeug’s internals by hand.