Authorization & Custom Decorators

The last two lessons answered "who are you?" — Flask-Login and password hashing establish a visitor's identity. This lesson answers a different question: once someone is logged in, what are they allowed to do? That's authorization, and it's a distinct concern from authentication — a perfectly authenticated, legitimately logged-in Bookshelf user should still be stopped from editing a book that belongs to someone else's shelf.

Authentication vs. Authorization

ConceptQuestion it answersCovered in
AuthenticationWho are you?Authentication with Flask-Login, Registration & Password Hashing
AuthorizationWhat are you allowed to do?This lesson

@login_required only proves someone is logged in as some user — it says nothing about whether that user should be allowed to touch the specific Book or Shelf row a URL points at. That check has to be written separately, inside the view.

The Most Common Case: Object-Level Ownership

For an app shaped like Bookshelf, the authorization need that comes up constantly is ownership: a Book row has a user_id column, and only the user it belongs to should be able to edit or delete it. The simplest possible version of this check lives right inside the view, using abort() — the same helper covered in Error Handling:

# books/views.py
from flask import abort, redirect, url_for
from flask_login import login_required, current_user
from models import Book

@app.route('/books/<int:book_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_book(book_id):
    book = Book.query.get_or_404(book_id)

    if book.user_id != current_user.id:
        abort(403)  # logged in, but this isn't your book

    # ...proceed with editing book...
    return render_template('books/edit.html', book=book)

This single if check is the core of object-level authorization: it doesn't matter that the visitor is genuinely logged in — book.user_id != current_user.id means this particular Book isn't theirs, and abort(403) stops the request with a "Forbidden" response instead of letting the edit through.

Avoiding Repetition: A Custom Decorator

Bookshelf needs this exact same ownership check on the edit view, the delete view, and probably a few more — copying the same three lines into every one of them is exactly the kind of repetition decorators exist to eliminate. Wrapping the check in an @owns_book decorator keeps every view that needs it down to one line:

# books/decorators.py
from functools import wraps
from flask import abort
from flask_login import current_user
from models import Book

def owns_book(view_func):
    @wraps(view_func)
    def wrapped_view(book_id, *args, **kwargs):
        book = Book.query.get_or_404(book_id)
        if book.user_id != current_user.id:
            abort(403)
        return view_func(book_id, *args, **kwargs)
    return wrapped_view

Applying it turns the earlier view into this:

# books/views.py
from .decorators import owns_book

@app.route('/books/<int:book_id>/edit', methods=['GET', 'POST'])
@login_required
@owns_book
def edit_book(book_id):
    book = Book.query.get(book_id)
    # ownership is already guaranteed by @owns_book — proceed directly
    return render_template('books/edit.html', book=book)
Note: @wraps(view_func) matters more than it looks. Without it, wrapped_view's own name and docstring replace edit_book's in Python's introspection — and Flask's routing relies on function names (as endpoint names) to be unique. Decorate several different views with an @owns_book that forgot @wraps, and Flask will raise a confusing "endpoint is already registered" error, because every wrapped view now looks identically named as wrapped_view. It's a subtle, easy-to-forget detail — always reach for functools.wraps when writing a decorator for a view function.

A Simple Role-Based Check: @admin_required

Bookshelf itself is single-tier — every account is an ordinary reader with no special privileges over anyone else's data. But plenty of real apps have more than one tier of user, and it's worth knowing the pattern even without a current need for it. The usual approach is a boolean column on User:

# models.py
class User(UserMixin, db.Model):
    # ...username, email, password_hash...
    is_admin = db.Column(db.Boolean, default=False, nullable=False)

paired with a decorator that checks it, following the same shape as owns_book:

# decorators.py
from functools import wraps
from flask import abort
from flask_login import current_user

def admin_required(view_func):
    @wraps(view_func)
    def wrapped_view(*args, **kwargs):
        if not current_user.is_admin:
            abort(403)
        return view_func(*args, **kwargs)
    return wrapped_view

This is a pattern to carry into whatever project actually needs it — a future admin dashboard for moderating shared content, for instance — rather than something Bookshelf's current single-tier design requires.

Stacking Decorators: Order Matters

The edit_book example above already stacks two decorators — @login_required and @owns_book — and the order they're written in is not arbitrary. Decorators apply bottom-up, but conceptually, the one closest to the function name runs its wrapping logic "closest" to the real view; here, that means @login_required's check happens before @owns_book's:

@app.route('/books/<int:book_id>/edit')
@login_required    # 1. runs first: is anyone logged in at all?
@owns_book         # 2. runs second: is current_user allowed to touch THIS book?
def edit_book(book_id):
    ...

This ordering isn't cosmetic. @owns_book's check reads current_user.id — if @login_required hadn't already guaranteed a real, authenticated user, current_user could be Flask-Login's anonymous placeholder, and comparing an anonymous "id" against book.user_id is meaningless at best and a potential bug at worst. Always put authentication decorators before authorization decorators that depend on current_user being real.

Coming up next: registration and password resets both eventually need to reach a user outside the browser entirely — Sending Email with Flask-Mail covers how Bookshelf sends a welcome email after signup.