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
| Concept | Question it answers | Covered in |
|---|---|---|
| Authentication | Who are you? | Authentication with Flask-Login, Registration & Password Hashing |
| Authorization | What 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)
@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.