flask-login
Session management for Flask, and only that. You give it a LoginManager, a user_loader callback that turns a stored id back into your user object, and user objects with four attributes it can read. In return it keeps the logged-in user's id in the Flask session, exposes that user as current_user in views and templates, guards views with a login_required decorator, and optionally issues a signed remember-me cookie. It has no opinion about your database, no password hashing, no registration, no roles and no tokens.
Still the standard way to hold a login session in a server-rendered Flask app, and its narrow scope is a feature when your user storage is unusual. Choose it knowing the release before this one was in 2022, that everything around the session (passwords, registration, redirect validation) is code you write, and that the security defaults on the remember-me cookie need tightening by hand.
Use it if
- You have a server-rendered Flask app with cookie sessions and want the login state plumbing without adopting a whole auth framework
- Your user storage is unusual (LDAP, an internal service, a document store) and you want to keep total control of how a user is loaded from an id
- You need step-up authentication, which login_fresh, fresh_login_required and confirm_login give you for sensitive views without a second session system
- You want the small surface deliberately: four attributes on a user object, one loader callback, one decorator, and current_user available in Jinja templates
- You expected authentication rather than session management. Password hashing, registration, email verification, password reset, rate limiting, OAuth and 2FA are all yours to write; this library never sees a password
- You need a maintained dependency: 0.6.3 was released on 2023-10-30 and there has been no release since, with the repository last pushed on 2025-08-27. It works and it is not archived, but nothing is moving, and PyPI still classifies it as Development Status 4 - Beta after thirteen years
- Your API is token-based. request_loader exists for header authentication, but this library is built around a server-side session cookie, and using it for a stateless JWT API means fighting the design
- You want safe redirects for free: the next parameter is placed on the login URL for you, but nothing validates it on the way back, so an unchecked redirect(request.args['next']) is an open redirect and the documentation leaves that check to you
- You are on Flask 2.2 or earlier with Werkzeug 3 installed, since the Werkzeug 3 compatibility fix only landed in 0.6.3 and older combinations break on import
- You need multi-tenant or role-based access control. There is no permission model at all, and the community answer has long been to bolt on a separate extension
Setup reality
Install is one package and two real dependencies (Flask and Werkzeug), but nothing works until app.secret_key is set, because the whole design stores the user id in Flask's signed session cookie. The user_loader callback receives a string, always, since the id round-trips through the session as text, so a database lookup with an integer primary key needs an int() conversion or every lookup silently misses. That callback must return None for an unknown id rather than raising, otherwise a stale session cookie turns into a 500 on every request. UserMixin supplies is_authenticated, is_active, is_anonymous and get_id, and get_id must return something stable; using an email that users can change logs everyone out of that account. Remember-me is off by default and its defaults are worth reading: a 365 day cookie, HttpOnly on, Secure off, and SameSite unset, so REMEMBER_COOKIE_SECURE and REMEMBER_COOKIE_SAMESITE are your job in production. session_protection defaults to 'basic', which marks a session non-fresh when the client identifier changes; setting it to 'strong' logs the user out instead, and that will sign out mobile users on flaky networks. login_view must be set or an unauthenticated request raises 401 instead of redirecting. The redirect target lands in request.args['next'] and is not validated, so check it against your own host before redirecting. For tests, FlaskLoginClient lets you set app.test_client_class and pass user= to skip the login flow.
Patterns
Wire the extension into an appsetup-login-manager
from flask import Flask
from flask_login import LoginManager
app = Flask(__name__)
app.secret_key = os.environ['SECRET_KEY']
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.session_protection = 'basic'
login_manager.init_app(app)Without secret_key nothing works, since the user id lives in the signed session cookie. Without login_view an unauthenticated request raises 401 instead of redirecting to your login page.
Load a user from a stored iddefine-user-loader
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))user_id always arrives as a string, so an integer primary key needs the int(). Return None for an unknown id; raising here turns a stale cookie into a 500 on every request.
Give your model the four required membersuser-model-contract
from flask_login import UserMixin
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String, unique=True)
disabled = db.Column(db.Boolean, default=False)
@property
def is_active(self):
return not self.disabledUserMixin supplies is_authenticated, is_active, is_anonymous and get_id. Override is_active to represent suspended accounts, because login_user refuses an inactive user unless force=True.
Start a session after checking a passwordlog-user-in
from werkzeug.security import check_password_hash
from flask_login import login_user
user = User.query.filter_by(email=form_email).first()
if user and check_password_hash(user.password_hash, form_password):
login_user(user, remember=form_remember)
return redirect(url_for('dashboard'))
return render_template('login.html', error='Invalid credentials')The password check is entirely yours; flask-login never sees it. Use the same generic error message for unknown user and wrong password so the form does not confirm which emails exist.
Redirect back safely after loginvalidate-next-redirect
from urllib.parse import urlparse
next_url = request.args.get('next')
if not next_url or urlparse(next_url).netloc:
next_url = url_for('dashboard')
return redirect(next_url)Nothing in the library checks this value. Redirecting straight to request.args['next'] is an open redirect, which is the single most common flask-login security bug.
Require a login for a viewprotect-view
from flask_login import login_required, current_user
@app.route('/settings')
@login_required
def settings():
return render_template('settings.html', user=current_user)Order matters: login_required must sit below the route decorator. OPTIONS requests are exempt by default, which keeps CORS preflight working.
Force reauthentication for sensitive actionsrequire-fresh-login
from flask_login import fresh_login_required, confirm_login
@app.route('/billing/card', methods=['POST'])
@fresh_login_required
def change_card():
...
@app.route('/reauth', methods=['POST'])
def reauth():
if check_password_hash(current_user.password_hash, request.form['password']):
confirm_login()
return redirect(request.args.get('next') or url_for('dashboard'))A session becomes non-fresh when it is restored from the remember-me cookie. Set login_manager.refresh_view or a needs_refresh_handler, otherwise the user gets a 401.
End the session and clear remember-melog-user-out
from flask_login import logout_user
@app.post('/logout')
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for('index'))logout_user removes the flask-login keys and cancels the remember cookie, but leaves everything else you put in the session. Make logout a POST so a link cannot log people out.
Fix the remember-me defaultsharden-remember-cookie
app.config.update(
REMEMBER_COOKIE_SECURE=True,
REMEMBER_COOKIE_SAMESITE='Lax',
REMEMBER_COOKIE_DURATION=timedelta(days=14),
SESSION_COOKIE_SECURE=True,
)The shipped defaults are 365 days, Secure off and SameSite unset. HttpOnly is already on. None of these are changed for you in production mode.
Support an API token alongside sessionsauthenticate-from-header
@login_manager.request_loader
def load_from_request(request):
token = request.headers.get('Authorization', '').removeprefix('Bearer ')
if not token:
return None
return User.query.filter_by(api_token=token).first()request_loader runs only when there is no session user, and it runs on every request, so keep the lookup cheap. The older header_loader is deprecated.
Return JSON instead of redirectingcustomise-unauthorized
@login_manager.unauthorized_handler
def unauthorized():
if request.accept_mimetypes.best == 'application/json':
return jsonify(error='authentication required'), 401
return redirect(login_url('auth.login', request.url))This replaces the redirect and the flashed message entirely. Use login_url to build the target so the next parameter is still attached.
Skip the login flow in teststest-as-logged-in-user
from flask_login import FlaskLoginClient
app.test_client_class = FlaskLoginClient
def test_settings_page(app, user):
with app.test_client(user=user) as client:
assert client.get('/settings').status_code == 200Setting test_client_class is what enables the user= argument. Without it the argument is rejected and you are back to posting the login form in every test.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| authlib | PyPI | You need OAuth 2, OpenID Connect or JWT handling rather than a server-side login session |
| flask-security-too | PyPI | You want registration, password hashing, confirmation email, roles and 2FA as a package instead of writing them |
| fastapi-users | PyPI | You are choosing a stack fresh for an API and would rather have a maintained, batteries-included auth layer on FastAPI |