Flask-Login Authentication — Deep Dive
Request lifecycle in detail
Every request with Flask-Login follows this sequence:
Request arrives
→ Flask creates request context
→ LoginManager.init_app registered a before_request hook
→ _load_user() runs:
1. Check session for '_user_id'
2. If found: call user_loader(id) → set current_user
3. If not found: check "remember me" cookie
4. If cookie found: decode it, call user_loader → set current_user, mark session NOT fresh
5. If nothing works: set current_user = AnonymousUserMixin
→ View function runs with current_user available
→ after_request: update "remember me" cookie if needed
→ Response sent
The loading happens in _load_user(), registered as a before_request handler. This means current_user is available in all view functions, template rendering, and after_request handlers — but not in before_app_request handlers that run before Flask-Login’s hook.
Remember-me cookie internals
When login_user(user, remember=True) is called, Flask-Login sets a cookie containing a signed token:
# Simplified internal logic
def _set_cookie(user):
data = f'{user.get_id()}|{sha512(user.get_id() + session_key)}'
response.set_cookie(
'remember_token',
value=data,
max_age=REMEMBER_COOKIE_DURATION, # Default: 365 days
httponly=True,
secure=app.config.get('REMEMBER_COOKIE_SECURE', False),
samesite='Lax'
)
The cookie value is the user ID plus an HMAC signature. When a user returns without a session, Flask-Login:
- Reads the remember cookie
- Verifies the signature against the app’s secret key
- Extracts the user ID
- Calls
user_loaderto get the user object - Restores the session but marks
_fresh = False
Security implication: If the secret key is compromised, all remember-me cookies can be forged. Rotate the secret key to invalidate all existing cookies.
Configuring remember-me security
app.config.update(
REMEMBER_COOKIE_DURATION=timedelta(days=30), # Shorter than default 365
REMEMBER_COOKIE_SECURE=True, # HTTPS only
REMEMBER_COOKIE_HTTPONLY=True, # No JavaScript access
REMEMBER_COOKIE_SAMESITE='Lax', # CSRF mitigation
REMEMBER_COOKIE_NAME='remember', # Custom cookie name
)
For high-security applications, implement token rotation — generate a new remember token on each use:
@login_manager.user_loader
def load_user(user_id):
user = User.query.get(int(user_id))
return user
# Custom token approach with rotation
class User(UserMixin, db.Model):
remember_token = db.Column(db.String(256))
def get_id(self):
return f'{self.id}:{self.remember_token}'
@login_manager.user_loader
def load_user(composite_id):
parts = composite_id.split(':', 1)
if len(parts) != 2:
return None
user = User.query.get(int(parts[0]))
if user and user.remember_token == parts[1]:
return user
return None
Changing remember_token (e.g., on password change) invalidates all existing sessions for that user.
Session protection modes
Flask-Login offers three session protection levels:
login_manager.session_protection = 'strong' # Default: 'basic'
- None — No protection. Sessions persist regardless of client changes.
- basic — If the request’s IP or user agent changes, the session is marked non-fresh. Users can continue browsing but
fresh_login_requiredviews require re-authentication. - strong — If the request’s IP or user agent changes, the session is destroyed entirely. The user must log in again.
The detection uses a hash of the user agent and IP address stored as _id in the session:
# Internal: session identifier generation
def _create_identifier():
user_agent = request.headers.get('User-Agent', '')
base = f'{request.remote_addr}|{user_agent}'
return sha512(base.encode('utf-8')).hexdigest()
Tradeoff: strong mode breaks for users behind load balancers with rotating IPs or users on mobile networks where IPs change frequently. Use basic for most applications and strong only for high-security contexts.
Role-based access control
Flask-Login provides authentication. Authorization (who can do what) needs additional logic:
Decorator-based approach
from functools import wraps
from flask import abort
from flask_login import current_user
def role_required(*roles):
def decorator(f):
@wraps(f)
@login_required
def decorated(*args, **kwargs):
if current_user.role not in roles:
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
@app.route('/admin')
@role_required('admin', 'superadmin')
def admin_panel():
return render_template('admin.html')
Permission-based approach
For finer-grained control, use a permission system:
class Permission:
READ = 1
WRITE = 2
MODERATE = 4
ADMIN = 8
class Role(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
permissions = db.Column(db.Integer, default=0)
def has_permission(self, perm):
return self.permissions & perm == perm
class User(UserMixin, db.Model):
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
role = db.relationship('Role')
def can(self, permission):
return self.role is not None and self.role.has_permission(permission)
def permission_required(permission):
def decorator(f):
@wraps(f)
@login_required
def decorated(*args, **kwargs):
if not current_user.can(permission):
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
@app.route('/moderate')
@permission_required(Permission.MODERATE)
def moderate_content():
return render_template('moderate.html')
Bitwise permissions are efficient and allow combining: Permission.READ | Permission.WRITE grants both.
Alternative user loaders
Beyond the standard user_loader, Flask-Login supports specialized loaders:
Request loader (for API tokens)
@login_manager.request_loader
def load_user_from_request(request):
# API key in header
api_key = request.headers.get('X-API-Key')
if api_key:
return User.query.filter_by(api_key=api_key).first()
# Bearer token
auth = request.headers.get('Authorization', '')
if auth.startswith('Bearer '):
token = auth[7:]
return User.verify_auth_token(token)
return None
The request loader runs when the session-based loader fails. This lets you support both browser sessions and API tokens in the same application.
Session storage backends
Flask’s default session storage is a signed cookie. This has limits:
- Size: Cookies max out around 4KB
- Security: Session data is visible to the client (though signed)
- Invalidation: You can’t force-logout a user by deleting their session server-side
For production, use server-side sessions:
from flask_session import Session
app.config.update(
SESSION_TYPE='redis',
SESSION_REDIS=redis.from_url('redis://localhost:6379'),
SESSION_PERMANENT=True,
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
)
Session(app)
Server-side sessions store data in Redis (or database, filesystem, etc.). The cookie contains only a session ID. This enables:
- Force logout — Delete the session from Redis
- Session listing — See all active sessions for a user
- Large session data — No cookie size limits
Concurrent session control
Limit users to one active session (or N sessions):
class User(UserMixin, db.Model):
current_session_id = db.Column(db.String(256))
@app.after_request
def track_session(response):
if current_user.is_authenticated:
session_id = session.get('_id')
if current_user.current_session_id != session_id:
current_user.current_session_id = session_id
db.session.commit()
return response
@login_manager.user_loader
def load_user(user_id):
user = User.query.get(int(user_id))
if user and user.current_session_id != session.get('_id'):
# Another session took over — log out this one
return None
return user
When a user logs in on a new device, it overwrites current_session_id, effectively invalidating the old session.
Testing authentication
@pytest.fixture
def auth_client(app):
user = User(email='test@example.com')
user.set_password('testpass')
db.session.add(user)
db.session.commit()
client = app.test_client()
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
return client
def test_dashboard_requires_login(client):
resp = client.get('/dashboard')
assert resp.status_code == 302
assert '/login' in resp.headers['Location']
def test_dashboard_authenticated(auth_client):
resp = auth_client.get('/dashboard')
assert resp.status_code == 200
Setting _user_id directly in the session bypasses the login form, making tests faster and independent of the login endpoint.
Production hardening checklist
- Set
SESSION_COOKIE_SECURE = True— Cookies only sent over HTTPS - Set
SESSION_COOKIE_HTTPONLY = True— No JavaScript access to session cookie - Set
SESSION_COOKIE_SAMESITE = 'Lax'— CSRF mitigation - Use server-side sessions — Don’t store sensitive data in client cookies
- Implement session timeout —
PERMANENT_SESSION_LIFETIME = timedelta(hours=8) - Rotate session on login — Call
session.regenerate()afterlogin_user()to prevent session fixation - Rate-limit login attempts — Flask-Limiter on the login endpoint
- Log authentication events — Record logins, failures, and logouts for audit trails
- Use
strongsession protection for admin routes,basicfor general users
One thing to remember: Flask-Login’s entire job is mapping a session cookie to a user object via your user_loader. Every feature — remember-me, session protection, fresh login — is a variation on how and when that mapping happens. Understanding the request lifecycle hook order explains every behavior and edge case.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.