Authentication with Flask-Login and password hashing

Give users real passwords with Werkzeug hashing, keep them signed in with Flask-Login, protect routes, and gate features by role without breaking open redirects.

Wiring up Flask-Login

# app/extensions.py
from flask_login import LoginManager
login_manager = LoginManager()
login_manager.login_view = "auth.login"          # endpoint used by @login_required
login_manager.login_message = "Please sign in to continue."
login_manager.login_message_category = "info"
login_manager.session_protection = "strong"      # invalidate on fingerprint change

# app/models.py
from flask_login import UserMixin

class User(UserMixin, db.Model):
    __tablename__ = "users"
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(255), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)
    role = db.Column(db.String(20), default="reader", nullable=False)
    active = db.Column(db.Boolean, default=True, nullable=False)

    @property
    def is_active(self):          # UserMixin reads this to block suspended accounts
        return self.active

# app/__init__.py — inside create_app()
login_manager.init_app(app)

@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))    # must return None, never raise
# app/auth/views.py
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user

bp = Blueprint("auth", __name__, url_prefix="/auth")

@bp.post("/login")
def login():
    email = request.form.get("email", "").strip().lower()
    password = request.form.get("password", "")
    user = db.session.execute(
        db.select(User).filter_by(email=email)).scalar_one_or_none()

    if user is None or not user.check_password(password):
        flash("Invalid email or password.", "error")     # same message for both cases
        return render_template("auth/login.html"), 401
    if not user.active:
        flash("This account is suspended.", "error")
        return render_template("auth/login.html"), 403

    login_user(user, remember=request.form.get("remember") == "on")
    next_url = request.args.get("next")
    return redirect(next_url if is_safe_url(next_url) else url_for("blog.index"))

@bp.post("/logout")
@login_required
def logout():
    logout_user()
    return redirect(url_for("blog.index"))

# safely validate a redirect target
from urllib.parse import urljoin, urlsplit

def is_safe_url(target):
    if not target:
        return False
    host = urlsplit(request.host_url)
    test = urlsplit(urljoin(request.host_url, target))
    return test.scheme in {"http", "https"} and test.netloc == host.netloc
ObjectMeaning
current_userThe loaded user, or an anonymous object — always available in a request
current_user.is_authenticatedA property, not a method: never write is_authenticated()
@login_requiredRedirects anonymous users to login_view with ?next=
login_user(user, remember=True)Starts the session, or a long-lived remember cookie
logout_user()Clears the session keys and the remember cookie
@fresh_login_requiredRequires a recent login for sensitive changes
session_protection="strong"Logs the user out when the browser fingerprint changes

Password hashing

from werkzeug.security import generate_password_hash, check_password_hash

# default method is scrypt in current Werkzeug; it salts per password
h = generate_password_hash("correct horse battery staple")
check_password_hash(h, "correct horse battery staple")     # True
check_password_hash(h, "wrong")                            # False

# moving from an old hash on successful login
if check_password_hash(user.password_hash, raw) and user.password_hash.startswith("pbkdf2:"):
    user.password_hash = generate_password_hash(raw)       # rehash with the new method

# cost tuning: slower is safer, but it is paid on every login
generate_password_hash(raw, method="scrypt", scrypt__n=2**15)
  • Hashes are salted and deliberately slow. Never compare password hashes with == in application code — check_password_hash is constant-time.
  • Do not log passwords, password hashes or reset tokens, and never build a session token out of a hash: rotate the session instead (login_user() on a fresh session, after session.clear() at login to prevent fixation).
  • A password reset flow sends a single-use, expiring token by email and checks it server-side. An email address plus a guessable parameter is not a reset flow.
  • Set SECRET_KEY from the environment: changing it invalidates every existing session and remember cookie, which is exactly what you want after a key leak.

Roles and route protection

from functools import wraps
from flask import abort, current_app
from flask_login import current_user

def roles_required(*roles):
    def decorator(view):
        @wraps(view)                       # keeps the endpoint name intact
        def wrapper(*args, **kwargs):
            if not current_user.is_authenticated:
                return current_app.login_manager.unauthorized()
            if current_user.role not in roles:
                abort(403)                 # authenticated but not allowed
            return view(*args, **kwargs)
        return wrapper
    return decorator

@bp.get("/admin/reports")
@login_required
@roles_required("admin", "editor")
def reports():
    return render_template("admin/reports.html")

# templates get current_user for free; hide what the user cannot use
{% if current_user.is_authenticated %}
  <span>{{ current_user.email }}</span>
  {% if current_user.role == "admin" %}<a href="{{ url_for('admin.reports') }}">Reports</a>{% endif %}
{% endif %}

Order matters: put @login_required above @roles_required so an anonymous user is redirected rather than receiving 403. @wraps is not cosmetic — without it, Flask registers the endpoint as wrapper and url_for by endpoint name fails.

⚠️
Hiding a link in a template is presentation, not authorisation. Any protected action must be checked again on the server for that request, because a client can send the POST directly. Equally, roles gate actions but not rows: a reader who owns a post must not be able to read another reader's post by changing an id in the URL, so filter every query by ownership and return 404 — not 403 — when the row belongs to someone else.

FAQ

Why does current_user look anonymous inside a template?
Flask-Login loads the user in a before_request hook, so it works for requests that pass through the app. Code that renders a template inside a background job or a CLI command has no request and no session, so current_user is anonymous there. Pass the user explicitly in those contexts.
Is the default session cookie enough to stay logged in?
The session cookie is signed, so its contents cannot be edited, but it is not encrypted and it lives in the browser. Keep only an identifier in it — Flask-Login stores the user id — and let the database hold everything that matters. Mark it HttpOnly, Secure and SameSite=Lax in production.

Databases with Flask-SQLAlchemy Building and testing a JSON API

Last refreshed 2026-09-18.