Learning Goals
3 min- Hash passwords with
bcrypt(orwerkzeug.security). - Build sign-up + login routes that read posts safely.
- Store the logged-in user's id in
flask.session. - Protect routes with a
@login_requireddecorator.
Warm-Up · Why Hash?
5 minImagine a thief steals your database file. If passwords are stored in plain text, every account is instantly compromised. If they're bcrypt-hashed, the thief has nothing useful — bcrypt is a one-way function with a built-in slow-down (work factor) that defeats brute force.
plain: "hunter2" bcrypt: $2b$12$Kix5JzZ4qmJM.JZuC1tt2..Zo7TcQHvIRP9.0xqXcXg8mZqxqJ2Aq
Never store a plain-text password. Never. Hash on sign-up; on login, hash the attempt and bcrypt.checkpw against the stored hash.
New Concept · Hash, Verify, Session
14 minInstall
pip install bcrypt
Hash + verify
import bcrypt # on sign-up hashed = bcrypt.hashpw(plain.encode(), bcrypt.gensalt()) # bytes # on login ok = bcrypt.checkpw(attempt.encode(), hashed) # True / False
Storing hashes in SQLite
bcrypt hashes are bytes; store them as TEXT (utf-8 decoded) or as BLOB. Most apps store as TEXT:
# in users table: password TEXT NOT NULL con.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode())) row = con.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() if row and bcrypt.checkpw(attempt.encode(), row["password"].encode()): # login OK ...
flask.session
Flask's session is a signed cookie. You can read/write it like a dict. It is NOT secret — the browser can see its contents — but it is signed so the user can't change it. Never put secrets in there; do put user_id, role, csrf token.
from flask import session session["user_id"] = row["id"] # log in session.pop("user_id", None) # log out
@login_required decorator
from functools import wraps from flask import session, redirect, url_for, flash def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if "user_id" not in session: flash("Please log in.") return redirect(url_for("login")) return view(*args, **kwargs) return wrapped
Worked Example · Auth on the Blog
12 min# auth_db.py import sqlite3, bcrypt from pathlib import Path DB = Path("blog.db") def get_conn(): con = sqlite3.connect(DB); con.row_factory = sqlite3.Row return con def init(): with get_conn() as con: con.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) def add_user(email, plain): hashed = bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode() with get_conn() as con: return con.execute( "INSERT INTO users (email, password) VALUES (?,?)", (email, hashed), ).lastrowid def authenticate(email, plain): with get_conn() as con: row = con.execute( "SELECT * FROM users WHERE email = ?", (email,) ).fetchone() if row and bcrypt.checkpw(plain.encode(), row["password"].encode()): return dict(row) return None
# app.py — auth routes from flask import Flask, render_template, request, redirect, url_for, flash, session from functools import wraps import auth_db as auth app = Flask(__name__) app.secret_key = "rotate-in-prod" auth.init() def login_required(view): @wraps(view) def wrapped(*a, **kw): if "user_id" not in session: flash("Please log in.") return redirect(url_for("login")) return view(*a, **kw) return wrapped @app.route("/signup", methods=["GET", "POST"]) def signup(): if request.method == "POST": email = request.form.get("email", "").strip().lower() pw = request.form.get("password", "") if "@" not in email or len(pw) < 8: flash("Bad email or password too short (≥ 8 chars).") return render_template("signup.html", email=email) try: uid = auth.add_user(email, pw) except sqlite3.IntegrityError: flash("Email already in use.") return render_template("signup.html", email=email) session["user_id"] = uid flash("Account created — you are logged in.") return redirect(url_for("index")) return render_template("signup.html") @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": email = request.form.get("email", "").strip().lower() pw = request.form.get("password", "") user = auth.authenticate(email, pw) if not user: flash("Wrong email or password.") return render_template("login.html", email=email) session["user_id"] = user["id"] flash("Logged in.") return redirect(url_for("index")) return render_template("login.html") @app.route("/logout") def logout(): session.pop("user_id", None) flash("Logged out.") return redirect(url_for("index")) @app.route("/new") @login_required def new(): return render_template("new.html")
Read the diff
Three small modules — db / auth / app — that don't leak responsibilities. The decorator hides the "please log in" check from every protected route. Passwords never appear in plain text anywhere except the brief moment between the browser and the server.
Try It Yourself
13 minIf session.user_id is set, show "Logout"; otherwise "Login / Sign up". Add a current_user Jinja global with app.context_processor.
Hint
@app.context_processor def inject_user(): uid = session.get("user_id") user = None if uid: with auth.get_conn() as con: user = dict(con.execute( "SELECT id, email FROM users WHERE id=?", (uid,)).fetchone()) return {"current_user": user}
Add a user_id column on posts. New posts get the logged-in user's id. Only allow the author to edit / delete their own posts.
Add a name column to users. Make "Posts by <name>" pages.
Mini-Challenge · Password Reset Skeleton
8 minSketch the flow: /reset/request takes an email and prints a fake reset link to the console; /reset/<token> shows a new-password form. Don't actually email; just print. Use secrets.token_urlsafe to generate tokens and store them in a password_resets table with expiry.
Recap
3 minHash with bcrypt on sign-up; checkpw on login. session stores user_id; session.pop logs out. Decorator handles auth checks. Email is the typical UNIQUE; lower-case before storing. Tomorrow: deeper sessions and the logout flow polished.
Homework
4 minAdd full auth to your blog from Lesson 41. Only logged-in users can post. The blog still works for guests as read-only. Push to GitHub.