CodingNic

Authentication and User Security

Mini project — multi-user Books app

Authentication and User Security 90 min read

Mini project — multi-user Books app

Mini project — multi-user Books app

For seven lessons, the auth-playground has been your sandbox. Each lesson added one concept — sessions, registration, hashing, login, logout, access control, personalisation. The playground works, but it has no real users, no real data, no real point beyond demonstrating the mechanics.

This lesson is the pivot. We close the playground and open the Books app you built in Module 4. The patterns you practised in the playground apply directly, but the integration isn’t a copy-paste job — your Books app has its own routes, its own templates, its own CSS conventions. You’ll bring authentication into that structure, not bolt it on the side.

By the end:

  • Visitors can register and sign in.
  • Passwords are hashed with scrypt + salt.
  • Sessions are signed cookies; logout is CSRF-safe.
  • Every book belongs to a specific user — its creator.
  • Each user only sees, edits, and deletes their own books.
  • The top bar shows the signed-in user’s name and a sign-out button.
  • Guests trying to reach private pages bounce to login, then land back where they were going.

This is the longest lesson in the module on purpose. Don’t rush it. Run the app after every task — if something breaks, you want to know which change broke it.


Prerequisites checklist

Before starting, confirm:

  • Your Books app from Module 4 runs. You can add, view, edit, and delete books.
  • You have models.py with the Book class.
  • You have app.py with five routes: index, add_book, view_book, edit_book, delete_book.
  • Your templates/ folder contains base.html, index.html, add.html, edit.html, detail.html.
  • Your static/styles.css is in place (the one with --primary: #4A6B5C and friends).

If any of those is missing or broken, finish Module 4 first.


Phase 1 — Add users to the data model

The Books app has one model: Book. We need a second: User. We also need to link each book to a user, so the database knows whose book is whose.

Task 1.1 — Add the User model

Open models.py. Below the existing Book class, add this:

python
# models.py — add at the top
from datetime import datetime

# ...existing Book class stays unchanged for now...


class User(db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80), nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(255), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    books = db.relationship("Book", backref="owner", lazy=True)

    def __repr__(self):
        return f"<User {self.email}>"

Three things worth pointing out:

  • email is unique=True — the database itself will reject duplicate addresses. This is the same field choice you made in the playground.
  • password is String(255) — sized generously because we’ll store hashes (around 100+ characters), not plain text.
  • books = db.relationship("Book", backref="owner", lazy=True) — this is the only line in models.py that’s genuinely new (the playground had no per-user data, so no relationship was needed). It does two things:
    • user.books returns all books belonging to a user.
    • book.owner returns the user object that owns a book (that’s what backref adds).

You don’t have to use the relationship in this lesson, but it’s the conventional way to model this and worth setting up properly.

Task 1.2 — Add the foreign key to Book

The Book model needs to know which user it belongs to. Add one column:

python
# models.py — inside the existing Book class, after notes
class Book(db.Model):
    __tablename__ = "books"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    author = db.Column(db.String(100), nullable=False)
    genre = db.Column(db.String(50))
    pages = db.Column(db.Integer)
    year = db.Column(db.Integer)
    status = db.Column(db.String(20))
    cover_url = db.Column(db.String(255))
    notes = db.Column(db.Text)

    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)

    def __repr__(self):
        return f"<Book {self.title}>"

The new line:

python
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
  • db.ForeignKey("users.id") — links this column to the users table’s id column. The string "users.id" refers to the __tablename__ = "users" we set in the User class.
  • nullable=False — every book must have an owner. This isn’t just data integrity; it’s a security guarantee. The database itself refuses to create a book without one. If we ever forget to set user_id in our code, the insert fails loudly instead of silently creating an orphan book.

Task 1.3 — Wipe and rebuild the database

SQLite doesn’t auto-migrate. db.create_all() will not retroactively add user_id to the existing books table — and even if it did, the existing books have no owner to assign them to.

For this project, delete the database file and let it recreate:

bash
rm instance/books.db

(Windows: del instance\books.db.)

Don’t run the app yet — there’s no way to register users, and db.create_all() will fail because the User model references users.id but the table doesn’t exist yet. The next phase fixes that.


Phase 2 — Bring authentication into the app

Now we add registration, login, logout, and the @login_required decorator.

Task 2.1 — Create auth.py

Create a new file in the project root (next to app.py):

python
# auth.py
from functools import wraps
from flask import session, flash, redirect, url_for, request


def login_required(view_func):
    @wraps(view_func)
    def wrapper(*args, **kwargs):
        if "user_id" not in session:
            flash("Please sign in first.")
            return redirect(url_for("login", next=request.path))
        return view_func(*args, **kwargs)
    return wrapper

This is the same decorator from Lesson 6. No changes needed — it’s a generic Flask helper that works in any app.

Task 2.2 — Update app.py imports and config

At the top of app.py, you need three new imports and one new config line. Replace the current imports and config section with this:

python
# app.py — top of file
import os
from flask import (
    Flask, render_template, request, redirect, url_for, flash, session
)
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "dev-only-change-me")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///books.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

db = SQLAlchemy(app)

from models import Book, User  # noqa: E402
from auth import login_required  # noqa: E402

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

Changes from the Module 4 version:

  • New imports: os, flash, session, generate_password_hash, check_password_hash, User, login_required.
  • app.secret_key is set from an environment variable, with a development fallback.
  • from models import Book becomes from models import Book, User so the table gets registered.

db.create_all() will now create both users and books tables. Run the app once just to let this happen, then stop it — we still need the routes.

Task 2.3 — Add the context processor

Right after the db.create_all() block (and before any route definitions), add:

python
# app.py — between db setup and routes
@app.context_processor
def inject_current_user():
    user_id = session.get("user_id")
    if user_id is None:
        return {"current_user": None}
    return {"current_user": User.query.get(user_id)}

This is the same context processor from Lesson 7. Every template now has access to current_user, which is either a User row or None for guests.

Task 2.4 — Add the register route

Add this route to app.py. Anywhere among your existing routes is fine — convention puts auth routes near the top.

python
# app.py — register route
@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"))

        if User.query.filter_by(email=email).first():
            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("Account created. Please sign in.")
        return redirect(url_for("login"))

    return render_template("register.html")

This is the same shape as the playground’s register handler from Lessons 2 and 3 combined — input validation plus hashing. No surprises.

Task 2.5 — Add the login route

python
# app.py — login route
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        email = request.form.get("email", "").strip().lower()
        password = request.form.get("password", "")

        if not email or not password:
            flash("Please enter your email and password.")
            return redirect(url_for("login"))

        user = User.query.filter_by(email=email).first()

        if user is None or not check_password_hash(user.password, password):
            flash("Invalid email or password.")
            return redirect(url_for("login"))

        session.clear()
        session["user_id"] = user.id
        session["user_name"] = user.name

        flash(f"Welcome back, {user.name.split()[0]}.")

        next_url = request.args.get("next")
        if next_url and next_url.startswith("/"):
            return redirect(next_url)
        return redirect(url_for("index"))

    return render_template("login.html")

Three things worth pointing out:

  • One combined error message for both wrong email and wrong password — defends against user enumeration (Lesson 4).
  • session.clear() before populating — defends against session fixation (Lesson 4).
  • The next parameter is validated with startswith("/") — defends against open-redirect attacks (Lesson 6).

These three security details are easy to skip when retyping the code from memory. Don’t.

Task 2.6 — Add the logout route

python
# app.py — logout route
@app.route("/logout", methods=["POST"])
def logout():
    session.clear()
    flash("You've been signed out.")
    return redirect(url_for("login"))

POST only — never GET. Same reason as the playground (Lesson 5): GET-triggered logout is exploitable via CSRF.


Phase 3 — Build the auth templates

The Books app already has a base.html and a styled set of templates. We’re going to add two new pages — register.html and login.html — that extend the existing base.html and use the existing CSS classes (form-card, field, req, btn btn-save, etc.). They should look like they’ve always belonged.

The playground used inline <style> blocks for these pages. We’re not doing that here. Instead, we’ll add a small set of new CSS rules to styles.css for the bits that don’t exist yet (auth card, user chip, flash banner).

Task 3.1 — Add the new CSS

Open static/styles.css and append these rules at the bottom:

css
/* ===== Auth additions (added in Module 5) ===== */
.user-bar {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  margin-bottom: 1rem;
}
.user-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 14px 4px 4px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: 13px;
  font-weight: 500;
  color: var(--text);
}
.user-chip .avatar {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--primary);
  color: var(--bg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 600;
}
.signout-form { margin: 0; }

.flash-stack { display: flex; flex-direction: column; gap: 8px; margin-bottom: 1.25rem; }
.flash {
  background: var(--danger-bg);
  border: 1px solid var(--danger-border);
  color: var(--text);
  padding: 10px 14px;
  border-radius: var(--radius-md);
  font-size: 13px;
}

.auth-card { max-width: 440px; margin: 2rem auto; }
.auth-card .form-card { padding: 1.5rem 1.75rem; }
.auth-card h1 { font-size: 22px; margin: 0 0 0.25rem; color: var(--text); }
.auth-card .lead { color: var(--text-muted); font-size: 13px; margin: 0 0 1.25rem; }
.auth-card .auth-footer { font-size: 13px; color: var(--text-muted); margin-top: 1rem; text-align: center; }

What each block does:

  • .user-bar, .user-chip, .avatar — the top-right cluster showing who’s signed in. Reuses your brand tokens (--surface, --border, --primary) so it looks at home.
  • .signout-form — strips the default form margin so the Sign out button sits neatly next to the chip.
  • .flash-stack, .flash — styling for flash messages, using your existing --danger-bg and --danger-border for visibility.
  • .auth-card — a centred narrow column for the login and register forms. Reuses .form-card (already in your CSS) for the actual card body.

No new colours. No new fonts. Every rule pulls from variables you already defined.

Task 3.2 — Update base.html

The Books app’s base.html is minimal — just the HTML shell and a content block. We’re going to add the user chip cluster and the flash messages block inside it.

Replace templates/base.html with this:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{% block title %}My to-read books{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
  {% block container %}
  <div class="container">

    {% if current_user %}
    <div class="user-bar">
      <div class="user-chip">
        <span class="avatar">{{ current_user.name[0]|upper }}</span>
        <span>{{ current_user.name }}</span>
      </div>
      <form action="{{ url_for('logout') }}" method="post" class="signout-form">
        <button type="submit" class="btn btn-cancel">Sign out</button>
      </form>
    </div>
    {% endif %}

    {% with messages = get_flashed_messages() %}
      {% if messages %}
      <div class="flash-stack">
        {% for msg in messages %}
          <div class="flash">{{ msg }}</div>
        {% endfor %}
      </div>
      {% endif %}
    {% endwith %}

    {% block content %}{% endblock %}
  </div>
  {% endblock %}
</body>
</html>

Two new blocks above the {% block content %}:

  • The user bar — wrapped in {% if current_user %} so guests never see a broken chip. Uses {{ current_user.name[0]|upper }} for the avatar so even lowercase-named users get a proud uppercase initial.
  • The flash stack — renders any pending flash messages. Now every page in the app automatically gets flash rendering for free.

You may notice the {% block container %} wrapper is the same as before — add.html and edit.html override it to use container-narrow, and that still works.

Task 3.3 — Create templates/register.html

html
{% extends "base.html" %}

{% block title %}Register{% endblock %}

{% block container %}
<div class="container-narrow">
  <div class="auth-card">
    <h1>Create your account</h1>
    <p class="lead">Sign up to start building your reading list.</p>

    {% with messages = get_flashed_messages() %}
      {% if messages %}
      <div class="flash-stack">
        {% for msg in messages %}
          <div class="flash">{{ msg }}</div>
        {% endfor %}
      </div>
      {% endif %}
    {% endwith %}

    <div class="form-card">
      <form action="{{ url_for('register') }}" method="post">
        <div class="field">
          <label for="name">Name <span class="req">*</span></label>
          <input id="name" name="name" type="text"
                 placeholder="e.g. Alice Carter" required>
        </div>

        <div class="field">
          <label for="email">Email <span class="req">*</span></label>
          <input id="email" name="email" type="email"
                 placeholder="you@example.com" required>
        </div>

        <div class="field">
          <label for="password">Password <span class="req">*</span></label>
          <input id="password" name="password" type="password"
                 minlength="8" required>
          <p class="hint">At least 8 characters.</p>
        </div>

        <div class="field">
          <label for="confirm_password">Confirm password <span class="req">*</span></label>
          <input id="confirm_password" name="confirm_password" type="password"
                 minlength="8" required>
        </div>

        <div class="form-actions">
          <div></div>
          <div class="actions-right">
            <button type="submit" class="btn btn-save">Create account</button>
          </div>
        </div>
      </form>
    </div>

    <p class="auth-footer">
      Have an account? <a href="{{ url_for('login') }}">Sign in</a>
    </p>
  </div>
</div>
{% endblock %}

Note the structure:

  • {% extends "base.html" %} — inherits the user bar and flash stack from the layout.
  • {% block container %} override** — uses container-narrow instead of container, the same trick add.html uses for a tighter form layout.
  • Field markup uses the existing classes: <div class="field">, <label> with <span class="req">*</span>, the <p class="hint"> for hints. Same pattern as add.html.
  • Action button uses btn btn-save (the dark green save action), and sits in the existing form-actions / actions-right cluster.
  • Local flash block repeated inside the auth-card — when there’s a validation error, we want the flash to sit inside the centred narrow column rather than above the user bar. (The base.html flash stack still appears in places that aren’t auth pages.)

Why repeat the flash block? Because the auth pages are visually centred and narrow, while the base.html flash stack lives in the wider container. We want the flashes to appear right where the user is looking — inside the auth card. The base layout’s flash block will never fire on /register or /login because we render flashes once already; it remains useful everywhere else (e.g., on the index page after sign-in).

Task 3.4 — Create templates/login.html

Same shape as register, just shorter:

html
{% extends "base.html" %}

{% block title %}Sign in{% endblock %}

{% block container %}
<div class="container-narrow">
  <div class="auth-card">
    <h1>Welcome back</h1>
    <p class="lead">Sign in to continue building your reading list.</p>

    {% with messages = get_flashed_messages() %}
      {% if messages %}
      <div class="flash-stack">
        {% for msg in messages %}
          <div class="flash">{{ msg }}</div>
        {% endfor %}
      </div>
      {% endif %}
    {% endwith %}

    <div class="form-card">
      <form action="{{ url_for('login') }}" method="post">
        <div class="field">
          <label for="email">Email <span class="req">*</span></label>
          <input id="email" name="email" type="email"
                 placeholder="you@example.com" required>
        </div>

        <div class="field">
          <label for="password">Password <span class="req">*</span></label>
          <input id="password" name="password" type="password" required>
        </div>

        <div class="form-actions">
          <div></div>
          <div class="actions-right">
            <button type="submit" class="btn btn-save">Sign in</button>
          </div>
        </div>
      </form>
    </div>

    <p class="auth-footer">
      New here? <a href="{{ url_for('register') }}">Create an account</a>
    </p>
  </div>
</div>
{% endblock %}

Two fields, one button, one footer link. Same visual language as the rest of the app.

Quick test: start the app, visit /register, sign up. You should land on /login with a flash message. Sign in. You should land on / — but it’ll be Module 4’s index page (no per-user scoping yet). The user chip should appear in the top-right corner. The auth flow is working; the books data isn’t multi-user yet. That’s Phase 4.


Phase 4 — Enforce login on book routes

Right now any visitor — guest or signed in — can still visit /, /add, /books/1, etc. We need to require login on all of them.

This is a one-line change per route: add @login_required directly below @app.route(...).

Task 4.1 — Apply the decorator to each book route

Find these five route definitions in app.py and add @login_required:

python
# app.py — index
@app.route("/")
@login_required
def index():
    books = Book.query.order_by(Book.id.desc()).all()
    return render_template("index.html", books=books)
python
# app.py — add_book
@app.route("/add", methods=["GET", "POST"])
@login_required
def add_book():
    # ... existing body unchanged for now ...
python
# app.py — view_book
@app.route("/books/<int:id>")
@login_required
def view_book(id):
    # ... existing body unchanged for now ...
python
# app.py — edit_book
@app.route("/books/<int:id>/edit", methods=["GET", "POST"])
@login_required
def edit_book(id):
    # ... existing body unchanged for now ...
python
# app.py — delete_book
@app.route("/books/<int:id>/delete")
@login_required
def delete_book(id):
    # ... existing body unchanged for now ...

Decorator order is critical. @app.route on top, @login_required directly below it. Reverse them and the route stays public.

Quick test: sign out, then try to visit / directly. You should bounce to /login?next=/. After signing in, you should land back on /.

But the books listed there are still everyone’s books. That’s the next set of tasks.


Phase 5 — Scope queries per user

Every place we wrote Book.query.all() or Book.query.get(id), we need to add “…and belonging to the current user.” This is the work that turns the app from “everyone shares one database” to “every user has their own world.”

Task 5.1 — Scope the index query

Update the index view:

python
# app.py — index
@app.route("/")
@login_required
def index():
    books = (
        Book.query
        .filter_by(user_id=session["user_id"])
        .order_by(Book.id.desc())
        .all()
    )
    return render_template("index.html", books=books)

One change: filter_by(user_id=session["user_id"]). Alice’s index now shows only Alice’s books.

Inside a view function, we read the user ID from session["user_id"] — not from current_user.id. The current_user variable only exists in templates (injected by the context processor). Inside Python code, the session is the source of truth.

Why session["user_id"] rather than session.get(...)? Because @login_required already guaranteed the key exists. By the time the view body runs, we’re safe to use direct bracket access.

Task 5.2 — Tag new books with their owner

Update the add_book view’s POST branch:

python
# app.py — add_book
@app.route("/add", methods=["GET", "POST"])
@login_required
def add_book():
    if request.method == "POST":
        book = Book(
            title=request.form.get("title", "").strip(),
            author=request.form.get("author", "").strip(),
            genre=request.form.get("genre") or None,
            pages=int(request.form["pages"]) if request.form.get("pages") else None,
            year=int(request.form["year"]) if request.form.get("year") else None,
            status=request.form.get("status") or "want",
            cover_url=request.form.get("cover") or None,
            notes=request.form.get("notes") or None,
            user_id=session["user_id"],
        )
        db.session.add(book)
        db.session.commit()
        return redirect(url_for("index"))
    return render_template("add.html")

One new line in the Book(...) constructor:

python
user_id=session["user_id"],

If you forget this line, the database will refuse the insert with NOT NULL constraint failed: books.user_id. That’s the nullable=False we set in Phase 1 doing its job.

Task 5.3 — Ownership checks on per-record routes

This is the most important step in the entire lesson.

@login_required checks “is someone signed in?” — not “is this their book?” Without an explicit ownership check, Bob could type /books/1 into his address bar and see Alice’s book. That’s the gap we’re closing now.

Add an ownership check at the top of every per-record route:

python
# app.py — view_book
@app.route("/books/<int:id>")
@login_required
def view_book(id):
    book = Book.query.get_or_404(id)
    if book.user_id != session["user_id"]:
        flash("You can't access that book.")
        return redirect(url_for("index"))
    return render_template("detail.html", book=book)
python
# app.py — edit_book
@app.route("/books/<int:id>/edit", methods=["GET", "POST"])
@login_required
def edit_book(id):
    book = Book.query.get_or_404(id)
    if book.user_id != session["user_id"]:
        flash("You can't access that book.")
        return redirect(url_for("index"))

    if request.method == "POST":
        book.title = request.form.get("title", "").strip()
        book.author = request.form.get("author", "").strip()
        book.genre = request.form.get("genre") or None
        book.pages = int(request.form["pages"]) if request.form.get("pages") else None
        book.year = int(request.form["year"]) if request.form.get("year") else None
        book.status = request.form.get("status") or "want"
        book.cover_url = request.form.get("cover") or None
        book.notes = request.form.get("notes") or None
        db.session.commit()
        return redirect(url_for("view_book", id=book.id))
    return render_template("edit.html", book=book)
python
# app.py — delete_book
@app.route("/books/<int:id>/delete")
@login_required
def delete_book(id):
    book = Book.query.get_or_404(id)
    if book.user_id != session["user_id"]:
        flash("You can't access that book.")
        return redirect(url_for("index"))
    db.session.delete(book)
    db.session.commit()
    return redirect(url_for("index"))

The same four-line check at the top of all three routes:

python
book = Book.query.get_or_404(id)
if book.user_id != session["user_id"]:
    flash("You can't access that book.")
    return redirect(url_for("index"))

If you only remember one thing from this lesson, remember this pattern. Authentication says “someone is signed in.” Ownership says “this is their record.” You need both checks on any route that touches a specific record.

Why redirect with a flash instead of returning a 404? Both approaches are defensible. A 404 hides whether the ID exists at all (slightly more privacy-preserving). A redirect with a flash is more honest about what’s happening. We’re choosing the friendlier UX here — for the playground or a tutoring product, learners benefit from clear feedback. In a higher-stakes app you might prefer 404.


Phase 6 — Personalise the index page

The app now works as a multi-user application. The last thing to do is make it feel like one — let the user know whose page they’re on, and greet new users warmly.

Task 6.1 — Personalise the subtitle

In templates/index.html, find this block at the top:

html
<div class="topbar">
  <div class="brand">
    <div class="logo">B</div>
    <div>
      <p class="app-title">My to-read books</p>
      <p class="app-subtitle">Your personal reading list</p>
    </div>
  </div>
  <a href="{{ url_for('add_book') }}" class="btn btn-primary">+ Add book</a>
</div>

Replace the <p class="app-subtitle">...</p> line with this:

html
<p class="app-subtitle">
  {% if current_user %}
    Welcome back, {{ current_user.name.split()[0] }}
  {% else %}
    Your personal reading list
  {% endif %}
</p>

{{ current_user.name.split()[0] }} takes just the first name. “Welcome back, Alice” reads better than “Welcome back, Alice Carter.”

Task 6.2 — Personalise the empty state

In the same template, find the {% else %} branch at the bottom (the empty state shown when books is empty):

html
{% else %}
<div class="form-card" style="text-align:center;">
  <p style="margin:0 0 12px; color: var(--text-muted);">
    You haven't added any books yet.
  </p>
  <a href="{{ url_for('add_book') }}" class="btn btn-primary">+ Add your first book</a>
</div>
{% endif %}

Replace the empty-state paragraph with this:

html
<p style="margin:0 0 12px; color: var(--text-muted);">
  {% if current_user %}
    Hi {{ current_user.name.split()[0] }} — your reading list is empty.
    Start by adding your first book.
  {% else %}
    You haven't added any books yet.
  {% endif %}
</p>

A small touch, but a brand-new user’s very first impression is now: “Hi Alice — your reading list is empty. Start by adding your first book.” That’s worlds better than the generic version.


Phase 7 — End-to-end acceptance test

A genuinely multi-user app needs to be tested as multiple users. Use two browsers, or one normal window plus one incognito window.

Walk through every step. Confirm each works exactly as described.

  1. Register Alice — visit /register, sign up with alice@example.com. Bounce to /login with a flash message: “Account created. Please sign in.”
  2. Sign in as Alice — bounce to / with the welcome-back flash. The top bar shows a green-circle A and “Alice Carter”, plus a Sign out button.
  3. Empty state — books grid shows the personalised “Hi Alice — your reading list is empty” message.
  4. Add three books as Alice — they appear on her list. Stats update. The subtitle still says “Welcome back, Alice.”
  5. Sign out — chip disappears, you bounce to the login page with the sign-out flash.
  6. In an incognito window, register Bob — same flow with bob@example.com.
  7. Sign in as Bob — empty state with “Hi Bob”. No sign of Alice’s books.
  8. Add two different books as Bob — they appear on his list. The “Total books” stat says 2, not 5.
  9. Bob tries to view Alice’s book — manually type /books/1. Redirected to / with the flash: “You can’t access that book.”
  10. Bob tries to edit Alice’s book — manually type /books/1/edit. Same redirect.
  11. Bob tries to delete Alice’s book — manually type /books/1/delete. Same redirect.
  12. Confirm Alice’s book is intact — in Bob’s incognito window, sign out. Sign in as Alice. Her three books are still there.
  13. Guest test — open a third incognito window. Try / directly. Bounce to /login with “Please sign in first.” The URL is /login?next=/.
  14. Bounce-back test — as a guest, type /add directly. Bounce to /login?next=/add. Sign in. You land on /add — not on the home page. The next parameter took you to your destination.
  15. Open-redirect test — sign out, then visit http://127.0.0.1:5000/login?next=https://example.com. Sign in. You should land on /, not on example.com. The startswith("/") check silently ignored the malicious next.

If all 15 steps pass, you have shipped a real, secure, multi-user web application.


Common pitfalls

These trip up almost everyone:

  • Forgetting to add user_id to Book(...) in add_book. The database refuses the insert (NOT NULL constraint failed: books.user_id). It’s easy to miss until you actually click “Save book.”
  • Querying Book.query.all() somewhere you missed. Search your app.py for the literal string Book.query and audit every match. Anywhere it’s called without a user_id filter or an ownership check, an authenticated user can see other users’ books.
  • Using current_user.id inside a view function. current_user only exists in templates (it’s injected by the context processor). Inside view functions, use session["user_id"].
  • Forgetting to delete books.db after adding the foreign key. The app will start fine, but INSERT will fail mysteriously. Delete the SQLite file and let it recreate.
  • Forgetting an @login_required. Audit all five book routes. Every one of them needs it.
  • Decorator order swap. @app.route must be above @login_required. Other way round and the route stays public. The endpoint name 'wrapper' is already in use error catches some cases but not all — visual inspection is the only reliable check.
  • Missing ownership check on edit or delete. Easy to add it to view_book and forget the others. Every per-record route needs the four-line ownership block at the top.

Stretch goals

If everything works and you want to push further:

  1. Filter by status. The dropdown in the search bar is currently decorative. Wire it up so selecting “Reading” only shows books with status="reading", combined with the user scope.
  2. Search. Make the search input actually search by title or author. Use SQLAlchemy’s ilike() for case-insensitive matching, scoped to the current user.
  3. A simple profile page. /profile shows the user’s email, join date, and book count.
  4. Change password. A form that asks for the current password (verified with check_password_hash), then a new password (hashed and saved).
  5. Delete account. A scary button that, after confirmation, deletes the user’s row and all their books in a single transaction. Test that signing in afterwards with the same email fails.

What you’ve actually built

Take a moment to appreciate this.

You took a single-user CRUD app from Module 4 and turned it into a genuinely secure multi-user application. Behind the scenes:

  • Passwords are hashed with scrypt and salted per-user.
  • Sessions are signed with a secret key, immune to tampering.
  • Logout is CSRF-resistant because it uses POST.
  • Every protected route runs through @login_required before any business logic.
  • Every per-record action checks ownership before performing the operation.
  • Open-redirect attacks via the next parameter are blocked.
  • The UI never reveals which fields were wrong on a failed login.
  • Stale sessions are wiped before new logins to defeat session fixation.

This is roughly the same architecture as a production web app. The remaining differences — server-side session storage, proper migrations, CSRF tokens on every form, rate limiting, password reset flows — are improvements, not foundations. You have the foundation.


Summary

  • The User model and the user_id foreign key turn the Books app from single-user to multi-user.
  • @login_required enforces that someone is signed in.
  • Per-record ownership checks enforce that it’s the right someone.
  • Every query is scoped to the current user — list, detail, edit, delete.
  • The user chip and personalised greetings put the session data on screen, building trust.
  • The 15-step acceptance test catches every common bug at once.

Outcome

You have built a real multi-user web application. From here, the rest of the module is contextual — short conceptual lessons on Flask-Login, JWT, OAuth, and roles. Those are previews of larger topics, but the core skill — building authenticated apps — you already have.

Take a screenshot of your finished app. Sign in as Alice. Sign in as Bob. See two completely different reading lists. That’s the moment to feel proud.