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.

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:

  1. Reads the remember cookie
  2. Verifies the signature against the app’s secret key
  3. Extracts the user ID
  4. Calls user_loader to get the user object
  5. 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_required views 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

  1. Set SESSION_COOKIE_SECURE = True — Cookies only sent over HTTPS
  2. Set SESSION_COOKIE_HTTPONLY = True — No JavaScript access to session cookie
  3. Set SESSION_COOKIE_SAMESITE = 'Lax' — CSRF mitigation
  4. Use server-side sessions — Don’t store sensitive data in client cookies
  5. Implement session timeoutPERMANENT_SESSION_LIFETIME = timedelta(hours=8)
  6. Rotate session on login — Call session.regenerate() after login_user() to prevent session fixation
  7. Rate-limit login attempts — Flask-Limiter on the login endpoint
  8. Log authentication events — Record logins, failures, and logouts for audit trails
  9. Use strong session protection for admin routes, basic for 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.

pythonflaskauthenticationsecurity

See Also