CodingNic

Authentication and User Security

Password hashing

Authentication and User Security 25 min read

Password hashing

Password hashing

In Lesson 2 you built the auth-playground app. It works. New users sign up, their details land in the database, and the /users page shows everyone who’s registered.

There’s just one problem. That Password (stored) column.

Open /users in your running playground app and look at it. You’ll see something like:

Name Email Password (stored)
Bob Reynolds bob@example.com hunter2pw
Alice Carter alice@example.com sunshine123

The passwords are right there. Anyone who can read this file knows everybody’s password.

This lesson is about why that’s so wrong, and the single, well-understood technique that fixes it: password hashing .

We’re going to do this in two parts. First, a quick experiment in a Python REPL to see hashing work — no Flask, no app, just typing into a terminal. Then we’ll change one line of the auth-playground’s app.py and watch those red passwords disappear forever.


Why plain passwords are dangerous

Imagine three scenarios. Each one is a real situation that happens regularly in the wild.

Scenario 1: a backup leaks. You upload a database backup to a cloud bucket and forget to mark it private. A bot finds it. The attacker now has every password in plain text.

Scenario 2: a contractor goes rogue. A developer who once worked on the system kept a copy of the database “just for testing.” Months later they’re upset about something, and they have everyone’s credentials.

Scenario 3: a SQL injection. Someone exploits a bug in a poorly-written search feature and dumps the users table. Game over.

In all three cases, the attacker now has something far more valuable than just access to your app. They have access to every account where a user reused that password — their email, their bank, their social media. People reuse passwords. It’s well known and well documented. When your database leaks, you’re not just leaking your own users — you’re leaking the keys to their entire digital life.

This is why the cardinal rule of authentication exists:

Never store passwords as plain text. Ever. For any reason.

There is no excuse, no edge case, no “but it’s just a small app” exception. The fix is so easy and so well-supported that doing anything else is negligence.


What hashing means

A hash function takes any input — a word, a password, a whole book — and produces a fixed-length string of characters that looks like random noise. Same input always gives the same output. Different inputs (almost always) give different outputs.

A toy example using a hash you’ve probably heard of, SHA-256:

code
"hello" → 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 "hello!" → ce06092fb948d9ffac7d1a376e404b26b7575bcb11ee3a4c4b9fbd4f0bbf6a78 "my password" → c0067d4af4e87f00dbac63b6156828237059172d1bbeac67427345d6a9fda484

Three properties matter for our purposes:

  1. Deterministic. “hello” always hashes to that exact same string, every time, forever.
  2. One-way. You cannot reverse a hash to get the original input. There’s no unhash() function. The only way to find the input is to guess and check.
  3. Avalanche. A tiny change in the input (“hello” vs “hello!”) produces a completely different hash. Hashes don’t gradually morph — they shatter.

That second property is the magic. We can store the hash instead of the password. When the user logs in, we hash their attempt and compare it to what we stored. If the two hashes match, the password matched.

We never store the password itself. We never need to.


Hands-on: meet Werkzeug

Flask comes with a battle-tested helper library called Werkzeug (it’s already installed — Flask depends on it). Werkzeug gives us two functions that handle every detail of password hashing:

  • generate_password_hash(password) — turns a plain password into a stored hash string.
  • check_password_hash(stored_hash, attempt) — checks whether a plain password matches a stored hash.

That’s the entire API. Two functions.

Let’s poke at them in the REPL before we modify our app. Open a new terminal in your auth-playground folder and start Python:

bash
python

Type each line, hit Enter, and read what comes back.

Experiment 1: turn a password into a hash

python
>>> from werkzeug.security import generate_password_hash
>>> generate_password_hash("sunshine123")
'scrypt:32768:8:1$Xp9b1eBKNxaGdVEq$0c2ae1cff5cbe6eead4ed6d0a3fdc898cdf...'

(Your actual output will differ — that’s the whole point of this lesson, as we’ll see in a moment.)

Three things to notice about the output:

  • It’s long. Around 100+ characters. That’s why we set String(255) on the password column back in Lesson 2 — to leave room for the hash.
  • It starts with metadata. The scrypt:32768:8:1$ prefix tells Werkzeug how this hash was made — the algorithm (scrypt) and its parameters. When verifying, Werkzeug reads that prefix and knows what to do automatically.
  • It’s not deterministic. That’s strange given we just said hash functions are deterministic. We’ll come back to it.

Experiment 2: hash the same password twice

python
>>> generate_password_hash("sunshine123")
'scrypt:32768:8:1$Xp9b1eBKNxaGdVEq$0c2ae1cff5cbe6eead4ed...'
>>> generate_password_hash("sunshine123")
'scrypt:32768:8:1$vZ8XUxSIC0D7iBv8$bebca18f6c98d24ceb25d1d5...'

Same input, different outputs. That looks broken. Hash functions are supposed to be deterministic, remember?

What’s happening: Werkzeug is automatically adding a salt — a small random value mixed into the password before hashing. Each call generates a fresh salt, so two users who chose the exact same password end up with two completely different stored hashes.

That salt is what makes the second segment (Xp9b1eBKNxaGdVEq vs vZ8XUxSIC0D7iBv8) differ between the two outputs above. The hash algorithm itself is still deterministic — but it’s hashing "sunshine123" + Xp9b1eBKNxaGdVEq the first time and "sunshine123" + vZ8XUxSIC0D7iBv8 the second time, so the outputs are inevitably different.

Why does this matter? Without salt, an attacker could precompute a giant table of common passwords and their hashes (called a rainbow table ) and just look up matches. With per-user salts, every single account needs to be cracked individually — even if a thousand users all picked “password123”, every one of them has a different stored hash.

The salt itself isn’t a secret. It’s stored inside the hash string (that’s what the middle segment is for). The point of a salt isn’t secrecy — it’s uniqueness. You don’t manage salts; Werkzeug handles everything.

Experiment 3: verify a password

python
>>> from werkzeug.security import check_password_hash
>>> stored = generate_password_hash("sunshine123")
>>> check_password_hash(stored, "sunshine123")
True
>>> check_password_hash(stored, "Sunshine123")
False
>>> check_password_hash(stored, "")
False

check_password_hash is the verification side. You give it the stored hash and a plain-text attempt; it returns True or False.

Notice you never reverse the hash. You can’t. What check_password_hash actually does is read the metadata and salt out of the stored hash, hash your attempt the same way, and compare the results. If they match, the original passwords matched.

About the hash algorithm: Werkzeug currently defaults to scrypt , a slow, memory-hard hash function that’s well-suited to passwords. Older tutorials may show pbkdf2:sha256:... instead — that’s the previous default, and still safe to use, just less secure. Either works for our purposes.


Slow on purpose

There’s one more property of password hash functions worth understanding: they’re slow on purpose .

This sounds like the opposite of what we’d want from software. Why would slowness be a feature?

Because of guessing.

If a hash takes a microsecond to compute, an attacker who steals your database can guess a million passwords per second trying to find one that matches. That’s a serious threat.

If a hash takes a tenth of a second to compute, the attacker can only try ten guesses per second . A password that would have fallen in a day now takes thirty years.

For your legitimate users, that tenth of a second is invisible — it happens once at login, and they’d never notice. For attackers, it’s the difference between feasible and impossible.

Look back at the scrypt prefix from Experiment 1: scrypt:32768:8:1. Those numbers tune how slow and memory-hungry the function is. Bigger numbers = slower = safer. Werkzeug’s defaults are tuned to give that “invisible to users, painful to attackers” balance.

This is also why you shouldn’t use plain SHA-256 (or MD5, or any general-purpose hash) for passwords. They’re too fast . An attacker with a modern GPU can hash billions of SHA-256 candidates per second. Password-specific hashes like scrypt, bcrypt, and argon2 are slow by design.


Fixing the auth-playground

Now let’s apply this to our app. Open app.py from Lesson 2 and find the register route.

You need to make two tiny changes .

Change 1: import the hash function

Near the top of app.py, add the import:

python
from werkzeug.security import generate_password_hash

Change 2: hash the password before saving

Find this block in the register route:

python
        user = User(
            name=name,
            email=email,
            password=password,  # plain text for now — fixed in Lesson 3
        )

Replace the password=password, line with:

python
            password=generate_password_hash(password),

That’s it.

The full updated app.py

For clarity, here’s the whole file with the changes applied:

python
# app.py
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash
)
from werkzeug.security import generate_password_hash
from models import db, User

app = Flask(__name__)
app.secret_key = "dev-only-change-me"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///playground.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

db.init_app(app)

with app.app_context():
    db.create_all()


@app.route("/")
def index():
    return redirect(url_for("register"))


@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        email = request.form.get("email", "").strip().lower()
        password = request.form.get("password", "")
        confirm = request.form.get("confirm_password", "")

        if not name or not email or not password:
            flash("Please fill in every field.")
            return redirect(url_for("register"))

        if password != confirm:
            flash("Passwords don't match.")
            return redirect(url_for("register"))

        if len(password) < 8:
            flash("Password must be at least 8 characters.")
            return redirect(url_for("register"))

        existing = User.query.filter_by(email=email).first()
        if existing:
            flash("An account with that email already exists.")
            return redirect(url_for("register"))

        user = User(
            name=name,
            email=email,
            password=generate_password_hash(password),
        )
        db.session.add(user)
        db.session.commit()

        flash(f"Account created for {email}.")
        return redirect(url_for("users"))

    return render_template("register.html")


@app.route("/users")
def users():
    all_users = User.query.order_by(User.created_at.desc()).all()
    return render_template("users.html", users=all_users)


if __name__ == "__main__":
    app.run(debug=True)

A couple of details worth pointing out:

  • The hash is computed after validation passes. Hashing is slow on purpose — no point computing it for a request that’s about to be rejected.
  • The User model didn’t change at all. The password column is still String(255), which we sized generously back in Lesson 2 precisely because we knew hashes were coming.
  • The templates/users.html page didn’t change. It just renders whatever’s in user.password — and now that’s a hash.

Clearing the old plain-text data

If you registered users during Lesson 2, their passwords are still sitting in the database as plain text. Hashing the registration handler doesn’t retroactively protect them.

For this playground, the simplest fix is to delete the database file and start fresh:

bash
rm instance/playground.db
python app.py

(On Windows: del instance\playground.db.)

In a real production app you can’t just wipe everyone’s accounts, of course. The proper migration would be: add a flag like password_needs_rehash, force those users to reset their password on next login, and then hash the new password when they set it. We’re not going that deep — for our playground, wiping the test users is fine.


Watch the change

Restart the app, register a new user (try alice@example.com with password sunshine123), and open /users.

What you’ll see:

Name Email Password (stored) Joined
Alice Carter alice@example.com scrypt:32768:8:1$Xp9b1eBKNxaGdVEq$0c2ae1cff5cbe6eead4ed6d0a3fdc… today

The red sunshine123 from Lesson 2 is gone. In its place sits a long, meaningless hash string. Even the user themselves couldn’t read their own password from that.

Now register a second user — say Bob — and deliberately give him the same password (sunshine123). Look at /users:

code
Alice → scrypt:32768:8:1$Xp9b1eBKNxaGdVEq$0c2ae1cff5cbe6ee... Bob → scrypt:32768:8:1$vZ8XUxSIC0D7iBv8$bebca18f6c98d24c...

Both picked the same password. Their stored hashes look completely unrelated. That’s the salt at work.

If an attacker stole this database now, all they’d get is the table above. Useless. They can’t reverse those hashes back to sunshine123. The most they can do is start guessing passwords, hashing each guess, and seeing if any match — and because scrypt is slow on purpose, that’s a hopeless effort.


Common mistakes

A few traps to watch for:

  • Hashing twice. If you accidentally hash the password before validation runs, and hash it again on save, you’ll store the hash of a hash. Login will silently never work later. Hash exactly once, only when you’re about to save.
  • Trying to “encrypt” instead of hash. Encryption is reversible — that’s its whole point. Passwords need to be irreversible. Hash, don’t encrypt.
  • Rolling your own with hashlib.sha256. Don’t write hashlib.sha256(password).hexdigest() and call it done. Plain SHA-256 is fast, has no salt, and is a known-bad choice for passwords. Use Werkzeug (or bcrypt, or argon2-cffi). Battle-tested libraries exist for a reason.
  • Storing the salt separately. You don’t need to. Werkzeug embeds it in the hash string. Don’t add a salt column to your User model — it’s already in there.
  • Showing the hash on a public page. We’re doing it in the playground for teaching, but no real app should expose the password column anywhere outside the database. Lesson 4 will let us drop the column from the /users table entirely.

Summary

  • Plain-text passwords are a critical security flaw — when (not if) your database leaks, you’ve leaked every user’s credentials.
  • A hash is a one-way fingerprint of an input. Same input → same hash. Cannot be reversed.
  • We store the hash. At login, we hash the attempt and compare against the stored hash.
  • Salt prevents attackers from precomputing common-password tables. Werkzeug adds and stores the salt automatically — that’s why the same password produces different stored hashes for different users.
  • Good password hashes are slow on purpose — invisible to users, painful to attackers. Werkzeug defaults to scrypt, which is the modern recommendation.
  • generate_password_hash() and check_password_hash() from werkzeug.security are the only two functions you need.
  • Fixing the playground took exactly two lines : an import and one replaced argument.

Outcome

The auth-playground no longer stores plain-text passwords. Every new account is protected by a salted, slow scrypt hash that an attacker would need decades to crack. Next, we put check_password_hash to work in the login page — and the playground will finally let users sign in to a session, just like a real app.