Authentication with Flask-Login

Flask has no login system of its own — no idea of "the current visitor," no session bookkeeping for who's signed in, nothing. That's deliberate, in keeping with the microframework philosophy from Why Flask?: authentication is exactly the kind of feature you pick an extension for. Flask-Login is the standard choice, and it's the piece that will let Bookshelf tell one visitor's shelves and books apart from another's. This lesson covers the whole flow: wiring the extension in, making the User model compatible with it, logging users in and out, protecting views, and reading the logged-in user anywhere in the app.

Installing and Initializing Flask-Login

Install it into Bookshelf's virtual environment the same way every other extension in this track has been installed:

pip install flask-login

Flask-Login is initialized the same way Flask-SQLAlchemy was in Project Structure & App Factory — create an unbound LoginManager at module level, then call init_app() on it inside the app factory once the real app instance exists:

# extensions.py
from flask_login import LoginManager

login_manager = LoginManager()
# app.py
from flask import Flask
from extensions import db, login_manager

def create_app():
    app = Flask(__name__)
    app.config.from_object('config.Config')

    db.init_app(app)
    login_manager.init_app(app)

    return app

Making User Compatible: UserMixin

Flask-Login needs any user object it works with to expose a small, specific set of properties and methods — is_authenticated, is_active, is_anonymous, and get_id(). Rather than writing those by hand on Bookshelf's User model, you inherit them from UserMixin, which supplies sensible defaults for all four:

# models.py
from flask_login import UserMixin
from extensions import db

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

UserMixin's default get_id() returns str(self.id), which is exactly right for Bookshelf's User.id primary key — no override needed. is_authenticated and is_active default to True for any instance that exists, which is the correct behavior unless a project later adds something like an account-suspension flag.

The Required user_loader Callback

Flask-Login stores only a user's id in the session — not the whole User object — and needs to be told how to turn that stored id back into a real User on every request. You provide that logic once, as a function decorated with @login_manager.user_loader:

# models.py
from extensions import login_manager

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

Without registering this callback, Flask-Login has no way to reconstruct current_user on subsequent requests, and every attempt to use it will fail. This is genuinely required — every Flask-Login setup needs exactly one user_loader.

Logging a User In: login_user()

A login view looks up the submitted username, verifies the password, and — on success — calls login_user() to tell Flask-Login "this visitor is now authenticated as this user." The password-checking details are covered fully in the next lesson, Registration & Password Hashing; here's the shape of the view with that check included:

# auth/views.py
from flask import request, redirect, url_for, flash
from flask_login import login_user
from werkzeug.security import check_password_hash
from models import User

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        user = User.query.filter_by(username=request.form['username']).first()

        if user and check_password_hash(user.password_hash, request.form['password']):
            login_user(user)
            return redirect(url_for('book_list'))

        flash('Invalid username or password.')

    return render_template('auth/login.html')

login_user(user) writes the user's id into the session (see Sessions & Cookies), so Flask-Login's user_loader can restore current_user on every following request, until they log out or the session expires.

Logging Out: logout_user()

Ending a session is a single call — logout_user() clears the recorded user id from the session:

# auth/views.py
from flask_login import logout_user, login_required

@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('login'))

Protecting Views: @login_required

Most of Bookshelf only makes sense for a logged-in visitor — adding a book, editing a shelf, rating something you've finished. Decorating a view with @login_required blocks anonymous visitors from reaching it entirely:

# books/views.py
from flask_login import login_required, current_user

@app.route('/books/add', methods=['GET', 'POST'])
@login_required
def add_book():
    # current_user is guaranteed to be a real, logged-in User here
    if request.method == 'POST':
        book = Book(
            title=request.form['title'],
            author=request.form['author'],
            user_id=current_user.id,
        )
        db.session.add(book)
        db.session.commit()
        return redirect(url_for('book_list'))

    return render_template('books/add.html')

An anonymous visitor who requests /books/add never reaches the function body at all — Flask-Login intercepts the request and redirects them before your code runs.

Where Unauthenticated Visitors Land: login_view and next

@login_required needs to know where to send a visitor who isn't logged in. You configure that once, right where login_manager is initialized:

# app.py
login_manager.init_app(app)
login_manager.login_view = 'login'  # the endpoint name of the login view

With that set, an anonymous visitor hitting /books/add is redirected to /login automatically — and Flask-Login appends the page they were originally headed for as a next query parameter, e.g. /login?next=/books/add. The login view reads it back after a successful login, so the visitor lands exactly where they intended instead of always bouncing to a generic homepage:

# auth/views.py
from flask import request, redirect, url_for
from werkzeug.urls import url_has_allowed_host_and_scheme

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        # ...username/password check and login_user(user) as shown above...
        next_page = request.args.get('next')
        return redirect(next_page or url_for('book_list'))

    return render_template('auth/login.html')
Note: a production app should validate that next points somewhere inside Bookshelf itself before redirecting to it (as the werkzeug.urls import above hints at) — otherwise a crafted link could redirect a freshly logged-in visitor to an external, malicious site. We'll return to this kind of caution in Security Best Practices.

Reading the Logged-In User: current_user

current_user is a context-local proxy, exactly like current_app from Application Context & Globals — it always resolves to whichever user Flask-Login has restored for the current request (via user_loader), or to an anonymous placeholder if nobody's logged in. It's available in views and, just as usefully, directly inside Jinja2 templates with no extra work — Flask-Login injects it into every template's context automatically. A nav bar that switches between "Log In" and "Log Out" is the classic example:

{% if current_user.is_authenticated %}
    <span>Signed in as {{ current_user.username }}</span>
    <a href="{{ url_for('logout') }}">Log Out</a>
{% else %}
    <a href="{{ url_for('login') }}">Log In</a>
{% endif %}

current_user.is_authenticated comes straight from UserMixin, and current_user.username reads the real column off the underlying User row — no separate lookup required, in the view or the template.

Coming up next: this lesson leaned on password checking without explaining it — Registration & Password Hashing covers exactly how Bookshelf creates new accounts and hashes passwords safely.