Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Sessions, Auth & Extensions

Right now anyone who can reach your notes app can create notes. There's no "logged in," no "this is my note" - the whole concept of a user doesn't exist yet. This phase fixes that the Flask way: a tiny built-in piece (the session) plus an extension you bolt on (Flask-Login).

The mental model: logging a user in is just remembering, across requests, that this browser belongs to a known person. HTTP forgets you between requests - that's the bare problem the Servlet sessions guide walks through from the ground up. Flask's answer is the trick every framework uses: an id rides along in a cookie, and the server reads it back on the next request. Flask wraps that in an object called session.

The Flask session

📝 flask.session is a dict-like object backed by a signed cookie. You write to it like a dictionary; Flask serializes those values, signs them, and ships them to the browser as a cookie. On the next request the browser sends the cookie back, Flask verifies the signature, and session is repopulated. Data you put in it persists across requests for that one user - which is exactly what "stay logged in" needs.

It needs one thing to work: a secret key, because that's what the signature is made with.

from flask import Flask, session, redirect, url_for

app = Flask(__name__)
app.secret_key = "change-me-to-a-long-random-value"  # signs the session cookie

@app.route("/visit")
def visit():
    # Read with a default, increment, write back.
    session["visits"] = session.get("visits", 0) + 1
    return f"You've visited {session['visits']} times."

What just happened: session behaves like a normal dict - session.get("visits", 0) reads a value (defaulting to 0 the first time), and session["visits"] = ... writes it. But it's not stored on the server: Flask packs the whole dict into the signed cookie. Reload the page and the count climbs, since the browser hands the cookie back each time. Without app.secret_key set, Flask refuses to use the session at all.

⚠️ Signed is not encrypted. The signature makes the cookie tamper-proof - change one byte and Flask rejects it - but by default the contents are only base64-encoded, not hidden. Anyone who reads the cookie can read its values. Never put secrets in the session: no passwords, no API keys, no private data. Store a reference (like a user id) and look up the sensitive stuff server-side. Same "the cookie is just the key" principle the Servlet sessions guide hammers on.

💡 Keep secret_key genuinely secret and random in production - load it from an environment variable, never hardcode it. If an attacker learns it, they can forge any session.

Auth = identity (a quick recap)

Before wiring up login, get one distinction straight - mixing them up is the root of most auth bugs.

📝 Authentication is who you are; authorization is what you're allowed to do. Logging in is authentication - proving you're the owner of an account. Deciding whether you may edit this particular note is authorization. This phase is about the first one; the authentication vs authorization guide draws the full line between them.

The other non-negotiable: how you store passwords.

⚠️ Never store passwords as plain text. Hash them. When a user signs up you store a one-way hash of their password, not the password itself; at login you hash what they typed and compare hashes. Werkzeug (which ships with Flask) gives you exactly the two functions for this:

from werkzeug.security import generate_password_hash, check_password_hash

hashed = generate_password_hash("hunter2")        # store THIS in the database
print(hashed)
# 'scrypt:32768:8:1$k9...': algorithm + parameters + salt + hash, all in one string

check_password_hash(hashed, "hunter2")   # True - correct password
check_password_hash(hashed, "wrong")     # False - wrong password

What just happened: generate_password_hash runs the password through a deliberately slow, salted hashing algorithm and returns a single string holding the algorithm, its parameters, the random salt, and the hash - that's what goes in your User table's password_hash column. At login, check_password_hash re-hashes the attempt with the stored salt and compares, returning True only on a real match. You never store, log, or compare the raw password. The how passwords are stored guide explains why hashing-with-salt is the only acceptable approach.

Flask-Login: the extension

Flask doesn't ship a login system - true to form, you add one. 💡 The standard choice is Flask-Login, the extension pattern from Phase 5 all over again: a focused library that integrates cleanly into Flask's request lifecycle and the session you just met. It manages the "is this browser logged in, and as whom?" bookkeeping so you don't hand-roll it.

It needs three pieces wired together: a LoginManager, a User model that mixes in UserMixin, and a user loader that turns a stored id back into a user object.

from flask_login import LoginManager, UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
# db is the Flask-SQLAlchemy handle from Phase 5

login_manager = LoginManager(app)
login_manager.login_view = "auth.login"   # where @login_required sends anonymous users

class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))   # called on every request to restore current_user

What just happened: LoginManager(app) plugs Flask-Login into the app and the session machinery. login_view tells it which route to bounce unauthenticated visitors to. The User model is an ordinary Flask-SQLAlchemy model - but UserMixin adds the properties Flask-Login expects for free (is_authenticated, get_id(), and friends). The @login_manager.user_loader is the linchpin: Flask-Login stores only the user id in the session cookie, and on every request it calls load_user with that id to fetch the full User - the "store a reference, look up the rest server-side" rule made concrete.

Now the login view itself - verify the password, then log the user in:

from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required

auth = Blueprint("auth", __name__)   # the auth blueprint from Phase 6

@auth.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        user = User.query.filter_by(username=request.form["username"]).first()
        if user and user.check_password(request.form["password"]):
            login_user(user)                      # <-- the session now remembers this user
            return redirect(url_for("notes.list_notes"))
        flash("Invalid username or password.")
    return render_template("login.html")

@auth.route("/logout")
@login_required
def logout():
    logout_user()                                 # forget the user for this session
    return redirect(url_for("auth.login"))

What just happened: on a POST we look up the user by username and verify the typed password with check_password (hash comparison, never plain text). If both check out, login_user(user) records the user's id in the session - from here on, every request from this browser is recognized as that user until they log out. We deliberately don't tell the visitor which field was wrong. logout_user() does the reverse. This all lives in an auth blueprint, from Phase 6.

Protecting routes

With login working, you can now demand it. 📝 @login_required is a decorator that gates a view behind authentication. Stack it on any route and Flask-Login intercepts anonymous visitors before your code runs, redirecting them to login_view. Here's the create-note route from Phase 5, now protected and stamping each note with its owner:

from flask_login import login_required, current_user

@notes.route("/notes", methods=["POST"])
@login_required                                   # must be logged in to reach this
def create_note():
    note = Note(
        title=request.form["title"],
        content=request.form["content"],
        user_id=current_user.id,                  # who created it
    )
    db.session.add(note)
    db.session.commit()
    return redirect(url_for("notes.list_notes"))

What just happened: @login_required sits between the route and the function. An anonymous visitor never reaches the body - Flask-Login redirects them to the login page and (if configured) remembers where they were headed. Inside the view, current_user is the logged-in User object Flask-Login restored via your user_loader; reading current_user.id records who owns the note. (The user_id column on Note is the relationship plumbing from Phase 5.)

current_user is available in templates too, which is how you show different UI to logged-in and logged-out visitors:

{% if current_user.is_authenticated %}
  <p>Signed in as {{ current_user.username }} - <a href="{{ url_for('auth.logout') }}">Log out</a></p>
  <a href="{{ url_for('notes.new_note') }}">New note</a>
{% else %}
  <a href="{{ url_for('auth.login') }}">Log in</a> to create notes.
{% endif %}

What just happened: current_user.is_authenticated is one of the properties UserMixin gave the User model - True for a logged-in user, False for an anonymous one. Flask-Login injects current_user into every template automatically, so you can branch on it without passing it in. Server-side @login_required is the real guard; this template check is just a clear UI cue on top of it.

The extension ecosystem

Sessions came from Flask's tiny core; everything else - the ORM in Phase 5, login here - arrived as an extension you chose and wired in. 💡 This is how Flask stays small: a rich ecosystem of focused extensions you compose into exactly the app you need. A few you'll meet constantly:

  • Flask-SQLAlchemy - the database/ORM layer (Phase 5).
  • Flask-Login - session-based authentication (this phase).
  • Flask-WTF - form handling and CSRF protection (Phase 4).
  • Flask-Migrate - schema migrations via Alembic (Phase 5's gotcha).
  • Flask-Mail - sending email.
  • Flask-CORS - cross-origin headers for APIs (handy in Phase 8).

⚠️ The flip side is real: you assemble and maintain the stack yourself. Django hands you auth, an ORM, an admin, and forms in one box, version-matched and integrated. Flask hands you a core and a catalog - you pick each piece, wire it in, and keep the versions playing nicely. For a small or focused app that freedom is a gift; for a large team it's overhead Django would have absorbed.

💡 Sessions plus Flask-Login give you real authentication; the extension model gives you everything else. You've now seen Flask's whole personality - a small, transparent core you grow by deliberate choices. Next we point that app outward and have it speak JSON.

Recap

  1. flask.session is a dict-like store backed by a signed cookie and needs app.secret_key. Write to it like a dict; values persist across requests for that user because the cookie round-trips on every request.
  2. ⚠️ Signed ≠ encrypted. The session cookie is tamper-proof but readable by default - never put secrets in it. Store a reference (a user id) and look up sensitive data server-side.
  3. Authentication (who you are) is separate from authorization (what you can do), and passwords must be hashed - use Werkzeug's generate_password_hash / check_password_hash, never plain text.
  4. Flask-Login is the standard auth extension: a LoginManager, a UserMixin model, a user_loader to restore current_user from the session, and login_user / logout_user in your views.
  5. @login_required gates a route behind authentication and redirects anonymous visitors to the login page; current_user gives you the logged-in user in views and templates (current_user.is_authenticated).
  6. 💡 Flask stays small via its extension ecosystem (Flask-SQLAlchemy, Flask-WTF, Flask-Login, Flask-Migrate, Flask-Mail, Flask-CORS…) - you compose them yourself, which is freedom for small apps and assembly work versus Django's all-in-one.

Quick check

Three questions on the ideas that have to stick before Phase 8:

[
  {
    "q": "The Flask session cookie is signed. What does that protect against, and what does it NOT protect against?",
    "choices": [
      "It protects against tampering (a forged cookie is rejected) but not against reading - values are not encrypted by default, so don't store secrets there",
      "It encrypts the contents, so it's safe to store passwords and API keys in the session",
      "It protects against the cookie being read by JavaScript, but allows tampering",
      "It hides the cookie from the browser entirely; only the server can see it"
    ],
    "answer": 0,
    "explain": "Signing makes the cookie tamper-proof - Flask rejects any modified cookie - but the values are only encoded, not encrypted. Anyone who reads the cookie reads the data, so store a reference (like a user id), never secrets."
  },
  {
    "q": "In a Flask-Login setup, what is the @login_manager.user_loader function for?",
    "choices": [
      "It takes the user id stored in the session and returns the full User object, so current_user is available on each request",
      "It checks the typed password against the stored hash during login",
      "It decides which routes require authentication",
      "It creates the session cookie and signs it on login"
    ],
    "answer": 0,
    "explain": "Flask-Login stores only the user id in the session. On every request it calls user_loader with that id to fetch the full User from the database and populate current_user - the 'store a reference, look it up server-side' pattern."
  },
  {
    "q": "You put @login_required on the create-note route. An anonymous visitor POSTs to it. What happens?",
    "choices": [
      "Flask-Login intercepts the request before the view body runs and redirects the visitor to the configured login_view",
      "The view runs but current_user is None, so you must check for it manually",
      "Flask returns a 500 error because no user is logged in",
      "The note is created and assigned to a default anonymous user"
    ],
    "answer": 0,
    "explain": "@login_required gates the view: an unauthenticated request never reaches your code. Flask-Login redirects the visitor to login_view (and can remember where they were headed), so the create-note logic only ever runs for a logged-in user."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. The Flask session cookie is signed. What does that protect against, and what does it NOT protect against?

2. In a Flask-Login setup, what is the @login_manager.user_loader function for?

3. You put @login_required on the create-note route. An anonymous visitor POSTs to it. What happens?