Login View
A login view typically:
- shows a login form (GET)
- validates credentials (POST)
- calls
login_user(user) - redirects to a protected page
Example (simplified)
Section titled “Example (simplified)”from flask import render_template, redirect, url_for, request, flash
from flask_login import login_user
from werkzeug.security import check_password_hash
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if not user or not check_password_hash(user.password_hash, password):
flash("Invalid username or password", "error")
return redirect(url_for("login"))
login_user(user)
return redirect(url_for("dashboard"))
return render_template("login.html")Remember to use PRG
Section titled “Remember to use PRG”After POST, redirect to avoid double submissions.
Next improvements
Section titled “Next improvements”In real apps, you’ll typically:
- use Flask-WTF for login form
- rate-limit login attempts
- add “next” parameter support for redirects
Never store a password. Store a slow hash of it.
Section titled “Never store a password. Store a slow hash of it.”from werkzeug.security import generate_password_hash, check_password_hash
h = generate_password_hash("hunter2")
# 'scrypt:32768:8:1$20NQrWQMGlecVTll$aedb65c008a339462616d32e...'
check_password_hash(h, "hunter2") # True
check_password_hash(h, "hunter3") # FalseMeasured, hashing the same password twice:
scrypt:32768:8:1$20NQrWQMGlecVTll$aedb65c008a339462616d32e
scrypt:32768:8:1$EpddHvz7mboBCHp1$954dfa0e5a2c040cf84845efDifferent results for the same password. Each hash carries its own random salt, so identical passwords do not produce identical hashes — which means an attacker cannot see which users share a password, and cannot precompute a table of common hashes.
flowchart LR P["password"] --> S["+ random salt"] S --> K["scrypt, 32768 rounds"] K --> H["method:params$salt$digest
stored as one string"] H --> V["check_password_hash reads
the params and salt back out"]
The stored string contains everything needed to verify it — algorithm, parameters, salt, digest — which is why upgrading the algorithm later does not invalidate old hashes.
The login view
Section titled “The login view”@app.route("/login", methods=["GET", "POST"])
def login():
form = LoginForm()
if form.validate_on_submit():
user = db.session.scalar(
sa.select(User).where(User.email == form.email.data))
if user is None or not check_password_hash(user.pw_hash, form.password.data):
flash("Invalid email or password.") # deliberately vague
return redirect(url_for("login"))
session.clear() # drop the anonymous session
session["user_id"] = user.id
return redirect(url_for("dashboard"))
return render_template("login.html", form=form)Four decisions in that block are worth naming:
- One error message for both failures. Saying “no such account” tells an attacker which addresses are registered — that is an account-enumeration hole.
session.clear()before settinguser_id. This is session fixation defence: if an attacker planted a session id before login, it is discarded rather than promoted to a logged-in one.- Redirect after success, so a refresh does not repost the credentials.
- Look up by a unique column and let the constraint guarantee there is at most one.
Protecting the pages behind it
Section titled “Protecting the pages behind it”from functools import wraps
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if session.get("user_id") is None:
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
@app.route("/dashboard")
@login_required
def dashboard():
...@wraps matters: without it every decorated view is named wrapped, and Flask raises on
the second one because two endpoints would share a name.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
Hashing the same password twice produced two different strings. Why?
The salt is stored inside the hash string alongside the algorithm and parameters. It stops precomputed tables and hides which users share a password.
pch.quizShowAnswer
B — each hash carries its own random salt, so identical passwords do not share a digest — The salt is stored inside the hash string alongside the algorithm and parameters. It stops precomputed tables and hides which users share a password.
-
Why should a login failure show the same message whether the email is unknown or the password is wrong?
That is account enumeration. A vague message costs a little usability and removes a reliable way to harvest valid addresses.
pch.quizShowAnswer
B — distinct messages let an attacker discover which email addresses have accounts — That is account enumeration. A vague message costs a little usability and removes a reliable way to harvest valid addresses.
-
Why call session.clear() immediately before setting session['user_id'] on success?
This is session fixation defence. Starting a fresh session at the moment privileges change means a pre-planted identifier is worthless.
pch.quizShowAnswer
B — to discard any session an attacker may have planted before login, rather than promoting it to a logged-in one — This is session fixation defence. Starting a fresh session at the moment privileges change means a pre-planted identifier is worthless.
-
Why does a login_required decorator need functools.wraps?
Flask derives the endpoint from the function name. Two views both called wrapped collide on registration.
pch.quizShowAnswer
B — without it every decorated view is named wrapped, so Flask raises when a second view registers the same endpoint name — Flask derives the endpoint from the function name. Two views both called wrapped collide on registration.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading