Skip to content

Login View

A login view typically:

  1. shows a login form (GET)
  2. validates credentials (POST)
  3. calls login_user(user)
  4. redirects to a protected page
python
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")

After POST, redirect to avoid double submissions.

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.”
hashing.py
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")     # False

Measured, hashing the same password twice:

two hashes of 'hunter2'
scrypt:32768:8:1$20NQrWQMGlecVTll$aedb65c008a339462616d32e
scrypt:32768:8:1$EpddHvz7mboBCHp1$954dfa0e5a2c040cf84845ef

Different 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.

diagram Diagram mermaid

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.

login.py
@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 setting user_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.
require_login.py
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.

sketch What a login attempt actually checks p5.js
The stored hash carries its own salt, so verification recomputes rather than compares. Both failure paths return the same message.
pch.quizTag pch.quizDefaultTitle
  1. Hashing the same password twice produced two different strings. Why?

    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.

  2. Why should a login failure show the same message whether the email is unknown or the password is wrong?

    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.

  3. Why call session.clear() immediately before setting session['user_id'] on success?

    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.

  4. Why does a login_required decorator need functools.wraps?

    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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading