Introduction to Flask-Login
Introduction to Flask-Login
You just spent eight lessons building authentication from scratch. You wrote sessions, a login_required decorator, a context processor, ownership checks, the whole stack — about 150 lines of authentication code spread across app.py, auth.py, and a few templates.
That code works. It’s safe. It’s yours. But every Flask developer who’s ever built a multi-user app has written some version of it. After people did this enough times, somebody packaged the common patterns into a library. That library is Flask-Login, and it’s the most popular authentication helper in the Flask ecosystem.
This lesson does two things. First, it explains what Flask-Login is and why it exists. Second, it walks you through building a fresh tiny app that uses it — so you experience the library, not just read about it.
You’re not converting the Books app or the auth-playground. We’re building a new mini-demo from scratch, side by side with what you already know. By the end, every Flask-Login API will feel familiar — because each piece replaces something you wrote by hand in the playground.
Why Flask-Login exists
Authentication has more rough edges than you’d think. You’ve felt some of them already:
- Loading the user once per request without writing it in every view.
- A
login_requireddecorator that handles thenextparameter correctly. - A
current_useravailable in templates and view functions alike. - Remembering a user across browser restarts (the “Remember Me” checkbox).
- Cleanly clearing the session on logout and on identity change.
Each piece is small. Together, they’re enough surface area that getting them all right takes more effort than most apps want to spend. Flask-Login provides battle-tested defaults for every one of them.
It’s intentionally opinionated, not a framework. It doesn’t tell you how to store users, how to hash passwords, or what your login form should look like. Those are your decisions. Flask-Login only handles the session side: who is logged in, how to remember them, how to enforce login on a route.
The five core ideas
Before we build, let’s tour the API. Each piece maps directly to something you wrote in the playground.
1. LoginManager
The library is bootstrapped with one object:
from flask_login import LoginManager
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login" # which route to redirect guests to
This is the central place that holds configuration (“send unauthenticated users to the login view,” “look up users with this function”). In your handwritten code, that configuration was spread across auth.py and a couple of view functions.
2. The user-loader callback
This is the one thing Flask-Login can’t do for you — because it doesn’t know where your users live. You provide a function that takes a user ID (always a string) and returns a user object (or None):
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
This replaces your context processor from Lesson 7. Flask-Login calls it once per request, caches the result, and exposes it as current_user.
3. The UserMixin
Flask-Login expects user objects to support four methods: is_authenticated, is_anonymous, is_active, get_id(). Rather than make you write them, it gives you a mixin to inherit:
from flask_login import UserMixin
class User(UserMixin, db.Model):
...
One word. Done.
4. login_user() and logout_user()
These replace your manual session writes:
login_user(user) # was: session["user_id"] = user.id (plus session.clear() for safety)
logout_user() # was: session.clear()
login_user(user) also regenerates the session ID to defend against session fixation — the same protection your session.clear() was providing.
5. current_user and @login_required
from flask_login import current_user, login_required
@app.route("/dashboard")
@login_required
def dashboard():
return f"Hello, {current_user.name}"
Identical idea to what you wrote in auth.py. Flask-Login’s @login_required also handles the next parameter automatically when login_manager.login_view is set.
Walkthrough: build a Flask-Login mini-app
Now let’s see it work. We’ll build a tiny standalone app that mirrors the playground’s feature set: register, login, logout, a protected welcome page, and a personalised greeting — but powered by Flask-Login throughout.
Setup
Create a new folder anywhere outside your other projects:
flask-login-demo/
├── app.py
└── templates/
├── base.html
├── register.html
├── login.html
└── welcome.html
Install the dependencies:
pip install flask flask-sqlalchemy flask-login
app.py
Single file, everything in it. Roughly 80 lines.
# app.py
import os
from flask import Flask, render_template, request, redirect, url_for, flash
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager, UserMixin, login_user, logout_user,
login_required, current_user
)
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:///login_demo.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
login_manager.login_message = "Please sign in first."
class User(UserMixin, 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)
with app.app_context():
db.create_all()
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("welcome"))
return redirect(url_for("login"))
@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", "")
if not name or not email or not password:
flash("Please fill in every field.")
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")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
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"))
login_user(user)
flash(f"Welcome back, {user.name.split()[0]}.")
return redirect(url_for("welcome"))
return render_template("login.html")
@app.route("/logout", methods=["POST"])
@login_required
def logout():
logout_user()
flash("You've been signed out.")
return redirect(url_for("login"))
@app.route("/welcome")
@login_required
def welcome():
return render_template("welcome.html")
if __name__ == "__main__":
app.run(debug=True)
Walk through what’s different from your playground:
from flask_login import ...at the top — six names that replaceauth.pyand your context processor.User(UserMixin, db.Model)— the mixin givesUserthe methods Flask-Login expects.login_manager.login_view = "login"— tells Flask-Login which view to send guests to when they hit a protected route. This is what makes@login_requiredredirect automatically.@login_manager.user_loader— the function that translates a user ID (from the session) into a user object. It replaces what your context processor used to do.current_user.is_authenticated— the new way to check “is someone signed in?” in templates and views. ReturnsTruefor logged-in users,Falsefor anonymous visitors.login_user(user)in the login route — that’s the entire session-creation step. Nosession["user_id"] = ...anywhere.logout_user()in the logout route — entire session-destruction step. Nosession.clear().@login_requiredon welcome and logout — identical to your handwritten decorator, except this one comes from the library.
That’s the whole difference. Same shape, less ceremony.
templates/base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Flask-Login demo{% endblock %}</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 520px;
margin: 0 auto;
padding: 0 20px;
color: #1f2a26;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 0;
border-bottom: 1px solid #e2ddd2;
margin-bottom: 32px;
}
.brand { font-size: 14px; font-weight: 600; color: #2f3e37; }
.brand a { color: inherit; text-decoration: none; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.user-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
background: #fff;
border: 1px solid #e2ddd2;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
}
.user-chip .avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4a6b5c;
color: #f4f1ea;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.nav-form { margin: 0; }
.nav-form button {
background: #fff;
color: #1f2a26;
border: 1px solid #d8d3c7;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
}
h1 { font-size: 22px; margin-bottom: 0.25rem; }
p.lead { color: #6b7a72; font-size: 14px; margin-top: 0; }
.card {
background: #fff;
border: 1px solid #e2ddd2;
border-radius: 12px;
padding: 24px;
margin-top: 20px;
}
.flash {
background: #f4e2dc;
border: 1px solid #e5c5bc;
color: #1f2a26;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
}
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
input {
width: 100%;
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid #d8d3c7;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
margin-bottom: 14px;
}
button.primary {
background: #2f3e37;
color: #fff;
border: none;
padding: 10px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.nav { font-size: 13px; color: #6b7a72; margin-top: 14px; }
.nav a { color: #4a6b5c; }
.field-pair { margin-bottom: 12px; }
.field-label { font-size: 12px; color: #6b7a72; margin: 0; }
.field-value { font-size: 14px; font-weight: 500; margin: 0; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand">
<a href="{{ url_for('index') }}">Flask-Login demo</a>
</div>
{% if current_user.is_authenticated %}
<div class="topbar-right">
<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="nav-form">
<button type="submit">Sign out</button>
</form>
</div>
{% endif %}
</div>
{% with messages = get_flashed_messages() %}
{% for msg in messages %}
<div class="flash">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
Notice:
{% if current_user.is_authenticated %}— the templates use the samecurrent_useryou used in the playground, with no setup of your own. Flask-Login made it available in every template the moment you calledlogin_manager.init_app(app).current_user.name— works directly. The user-loader returns the fullUserrow, so all its columns are accessible from the template.
The page templates
Small, almost identical to the playground versions:
<!-- templates/register.html -->
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<h1>Create an account</h1>
<p class="lead">Powered by Flask-Login.</p>
<div class="card">
<form action="{{ url_for('register') }}" method="post">
<label for="name">Name</label>
<input id="name" name="name" type="text" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" minlength="8" required>
<button type="submit" class="primary">Create account</button>
</form>
</div>
<p class="nav">Have an account? <a href="{{ url_for('login') }}">Sign in</a></p>
{% endblock %}
<!-- templates/login.html -->
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
<h1>Sign in</h1>
<p class="lead">Welcome back.</p>
<div class="card">
<form action="{{ url_for('login') }}" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" required>
<button type="submit" class="primary">Sign in</button>
</form>
</div>
<p class="nav">No account yet? <a href="{{ url_for('register') }}">Register</a></p>
{% endblock %}
<!-- templates/welcome.html -->
{% extends "base.html" %}
{% block title %}Welcome{% endblock %}
{% block content %}
<h1>Hi {{ current_user.name.split()[0] }} 👋</h1>
<p class="lead">You're signed in.</p>
<div class="card">
<div class="field-pair">
<p class="field-label">Name</p>
<p class="field-value">{{ current_user.name }}</p>
</div>
<div class="field-pair">
<p class="field-label">Email</p>
<p class="field-value">{{ current_user.email }}</p>
</div>
<div class="field-pair">
<p class="field-label">User ID</p>
<p class="field-value">{{ current_user.id }}</p>
</div>
</div>
{% endblock %}
Run it
From the project folder:
python app.py
Open http://127.0.0.1:5000. You’ll bounce to /login. Register an account. Sign in. You’ll land on /welcome — the chip in the top right shows your initial and name, and the page greets you by first name:
Hi Alice 👋
You’re signed in.Name: Alice Carter
Email: alice@example.com
User ID: 1
Now try:
- Visit
/welcomedirectly as a guest (open an incognito window). You bounce to/login. Notice the URL has?next=%2Fwelcome— Flask-Login added it automatically. - Sign in — you land back on
/welcome, not on/. Thenextparameter worked, without you writing any handling code. - Click Sign out. Back to login with the sign-out flash.
Same UX as the playground from Lessons 1–7. Less code.
Side-by-side: what changed
A summary of everything Flask-Login handled for you:
| You wrote (handwritten) | Flask-Login equivalent |
|---|---|
auth.py with @login_required |
Import from flask_login directly |
Context processor for current_user |
Provided by the library |
session["user_id"] = user.id |
login_user(user) |
session.clear() on logout |
logout_user() |
Manual next parameter handling |
Built in (via login_view) |
Custom next URL safety check |
Built-in URL safety helper |
| Hand-rolled session fixation defence | Built into login_user(user) |
Custom {% if current_user %} check |
{% if current_user.is_authenticated %} |
The library doesn’t do anything magical. It does what you did, packaged so you don’t have to think about it.
What Flask-Login does not do
Worth being clear about scope:
- It doesn’t store users. That’s still your database, your
Usermodel, your migrations. - It doesn’t hash passwords. You used Werkzeug here, just like in the playground. Flask-Login doesn’t care.
- It doesn’t render login forms. You write the HTML.
- It doesn’t provide registration. The
/registerroute in this demo is the same shape as the one in your playground. - It doesn’t do authorization.
@login_requiredonly checks “is someone signed in?” — not “is this their record?” The ownership checks from Lesson 8 still need to live in your views.
Flask-Login handles the session-and-current-user plumbing. Everything around that is still yours.
When to reach for it
For learning, building from scratch (as you did) is the right call. You understand sessions, hashing, decorators, and security trade-offs at a level you wouldn’t if you’d just installed a library and called login_user(user).
For shipping a real app, install Flask-Login. The reasons:
- It’s been hardened. Edge cases you haven’t thought of are handled.
- It plays well with other extensions. Flask-Principal (permissions), Flask-Security-Too (full auth suite), Flask-OAuthlib (social login) all integrate with Flask-Login’s
current_user. - It reduces your maintenance burden. Fewer of your lines means fewer of your bugs.
- It’s idiomatic. Other Flask developers reading your code will recognise the patterns instantly.
A common path:
- Build a small project from scratch (like the auth-playground) to internalise the concepts.
- For your next real project, install Flask-Login and use it.
- When something exotic is needed (custom session backends, single sign-on, JWT-based APIs), look at extensions or roll your own — but only the exotic piece.
You’ve done step 1 in this module. Step 2 you’ve just previewed in this lesson.
A note on alternatives
Flask-Login is the most popular, but it isn’t the only option:
- Flask-Security-Too — opinionated all-in-one (registration, password reset, role-based access). Uses Flask-Login under the hood. Faster to ship a complete auth system; less flexible.
- Authlib — focuses on OAuth and OpenID Connect (Lesson 11’s topic). Pairs well with Flask-Login.
- Flask-JWT-Extended — for API-style apps that use JWT tokens instead of session cookies (Lesson 10’s topic, coming next).
You don’t need to learn any of these now. Knowing they exist is enough.
Summary
- Flask-Login is a thin library that packages the patterns you’ve been writing by hand.
- The five building blocks:
LoginManager, auser_loadercallback,UserMixin,login_user()/logout_user(),current_user+@login_required. - You just built a complete demo using all of them. It’s about 80 lines.
- Flask-Login doesn’t replace your database, your password hashing, your forms, or your ownership checks.
- For learning, build from scratch. For shipping, use the library.
Outcome
You can now read any Flask codebase that uses Flask-Login and recognise every piece. More importantly, you’ve used it — you know what installing it actually feels like and what it spares you from writing. Next lesson, we look at a fundamentally different approach to authentication: tokens, used by APIs and mobile apps, where the session-cookie pattern doesn’t fit.