CodingNic

Authentication and User Security

Introduction to Flask-Login

Authentication and User Security 25 min read

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_required decorator that handles the next parameter correctly.
  • A current_user available 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:

python
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):

python
@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:

python
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:

python
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

python
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:

text
flask-login-demo/
├── app.py
└── templates/
    ├── base.html
    ├── register.html
    ├── login.html
    └── welcome.html

Install the dependencies:

bash
pip install flask flask-sqlalchemy flask-login

app.py

Single file, everything in it. Roughly 80 lines.

python
# 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 replace auth.py and your context processor.
  • User(UserMixin, db.Model) — the mixin gives User the 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_required redirect 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. Returns True for logged-in users, False for anonymous visitors.
  • login_user(user) in the login route — that’s the entire session-creation step. No session["user_id"] = ... anywhere.
  • logout_user() in the logout route — entire session-destruction step. No session.clear().
  • @login_required on 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

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 same current_user you used in the playground, with no setup of your own. Flask-Login made it available in every template the moment you called login_manager.init_app(app).
  • current_user.name — works directly. The user-loader returns the full User row, so all its columns are accessible from the template.

The page templates

Small, almost identical to the playground versions:

html
<!-- 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 %}
html
<!-- 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 %}
html
<!-- 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:

bash
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:

  1. Visit /welcome directly as a guest (open an incognito window). You bounce to /login. Notice the URL has ?next=%2Fwelcome — Flask-Login added it automatically.
  2. Sign in — you land back on /welcome, not on /. The next parameter worked, without you writing any handling code.
  3. 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 User model, 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 /register route in this demo is the same shape as the one in your playground.
  • It doesn’t do authorization. @login_required only 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:

  1. Build a small project from scratch (like the auth-playground) to internalise the concepts.
  2. For your next real project, install Flask-Login and use it.
  3. 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, a user_loader callback, 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.