Session Management in Python — Deep Dive
Django’s session framework
Django ships with a complete session framework out of the box. Session data is accessed via request.session, which behaves like a dictionary:
# views.py
def login_view(request):
user = authenticate(request.POST["username"], request.POST["password"])
if user:
# Regenerate session ID to prevent fixation
request.session.cycle_key()
request.session["user_id"] = user.id
request.session["login_time"] = str(datetime.now(timezone.utc))
return redirect("/dashboard")
return render(request, "login.html", {"error": "Invalid credentials"})
def dashboard_view(request):
user_id = request.session.get("user_id")
if not user_id:
return redirect("/login")
# user_id is available from the session
return render(request, "dashboard.html")
def logout_view(request):
request.session.flush() # Deletes session data AND cookie
return redirect("/login")
Django supports multiple backends configured in settings.py:
# Database backend (default)
SESSION_ENGINE = "django.contrib.sessions.backends.db"
# Cache backend (Redis via django-redis)
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
SESSION_CACHE_ALIAS = "default"
# Cached database (write-through: cache + DB for durability)
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
# Cookie-based (signed, not encrypted)
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
# Security settings
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_AGE = 1800 # 30 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_SAVE_EVERY_REQUEST = True # Reset expiry on activity
SESSION_SAVE_EVERY_REQUEST = True implements sliding expiration — each request resets the timeout clock. Without it, sessions expire at a fixed time regardless of activity.
Flask session implementations
Flask’s built-in sessions are client-side (signed cookies). For server-side sessions, use Flask-Session with Redis:
from flask import Flask, session
from flask_session import Session
import redis
app = Flask(__name__)
app.config.update(
SECRET_KEY="change-me-in-production",
SESSION_TYPE="redis",
SESSION_REDIS=redis.Redis(host="127.0.0.1", port=6379, db=0),
SESSION_PERMANENT=True,
PERMANENT_SESSION_LIFETIME=timedelta(minutes=30),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_KEY_PREFIX="myapp:",
)
Session(app)
@app.route("/login", methods=["POST"])
def login():
user = authenticate(request.form["username"], request.form["password"])
if user:
# Flask doesn't have cycle_key; regenerate manually
session.clear()
session["user_id"] = user.id
session.permanent = True
return redirect("/dashboard")
return render_template("login.html", error="Invalid credentials")
@app.route("/logout")
def logout():
session.clear()
return redirect("/login")
Flask-Session stores session data as serialized Python objects in Redis. The key is myapp:<session_id>, and Redis TTL handles expiration automatically.
FastAPI session patterns
FastAPI doesn’t have built-in sessions — it’s designed for stateless APIs. When you need sessions (admin panels, server-rendered pages), there are several approaches:
from fastapi import FastAPI, Request, Response
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(
SessionMiddleware,
secret_key="your-secret-key",
max_age=1800,
https_only=True,
same_site="lax",
)
@app.post("/login")
async def login(request: Request):
form = await request.form()
user = authenticate(form["username"], form["password"])
if user:
request.session["user_id"] = user.id
return RedirectResponse("/dashboard", status_code=303)
return {"error": "Invalid credentials"}
Starlette’s SessionMiddleware uses signed cookies (client-side). For server-side sessions with Redis:
from itsdangerous import URLSafeTimedSerializer
import redis
import json
import secrets
class RedisSessionManager:
def __init__(self, redis_url: str = "redis://localhost:6379/0"):
self.redis = redis.from_url(redis_url)
self.prefix = "session:"
self.ttl = 1800 # 30 minutes
def create(self, data: dict) -> str:
session_id = secrets.token_urlsafe(32)
key = f"{self.prefix}{session_id}"
self.redis.setex(key, self.ttl, json.dumps(data))
return session_id
def get(self, session_id: str) -> dict | None:
key = f"{self.prefix}{session_id}"
raw = self.redis.get(key)
if not raw:
return None
# Refresh TTL on access (sliding expiration)
self.redis.expire(key, self.ttl)
return json.loads(raw)
def destroy(self, session_id: str):
self.redis.delete(f"{self.prefix}{session_id}")
def regenerate(self, old_session_id: str) -> str:
data = self.get(old_session_id)
if data:
self.destroy(old_session_id)
return self.create(data)
return self.create({})
Session fixation prevention
Session fixation is an attack where the attacker sets the victim’s session ID to a known value before the victim logs in. After login, the attacker uses that same session ID to hijack the authenticated session.
Prevention requires regenerating the session ID after any privilege change (login, role escalation):
# Django
request.session.cycle_key() # Built-in method
# Flask — manual regeneration
old_data = dict(session)
session.clear()
# Flask-Session creates a new session ID when the old one is cleared
for key, value in old_data.items():
session[key] = value
# Custom implementation
new_id = session_manager.regenerate(old_session_id)
response.set_cookie("session_id", new_id, httponly=True, secure=True)
Scaling sessions across multiple servers
When your app runs on multiple servers behind a load balancer, sessions must be accessible from any server.
Sticky sessions (session affinity): The load balancer routes all requests from the same client to the same server. Simple but creates uneven load distribution and breaks if a server goes down.
Centralized store (Redis/Memcached): All servers read from and write to the same session store. The standard production approach. Redis with replication provides high availability.
# Redis Sentinel for high availability
import redis.sentinel
sentinel = redis.sentinel.Sentinel(
[("sentinel-1", 26379), ("sentinel-2", 26379), ("sentinel-3", 26379)],
socket_timeout=0.5,
)
session_store = sentinel.master_for("mymaster", socket_timeout=0.5)
Redis Cluster distributes session data across multiple Redis nodes. Use consistent hashing or key tags to ensure related session operations hit the same node.
Session data serialization security
Be careful with what you store in sessions and how it’s serialized:
# Dangerous: storing objects that use pickle serialization
# If session data is tampered with, pickle can execute arbitrary code
session["user"] = user_object # Don't do this
# Safe: store only primitive types
session["user_id"] = user.id
session["role"] = user.role
session["login_time"] = user.last_login.isoformat()
Flask-Session uses pickle by default for serialization. In production, switch to JSON serialization or use msgpack. Pickle deserialization of untrusted data is a remote code execution vulnerability.
Session monitoring and cleanup
Expired sessions accumulate in database backends. Django provides a management command:
# Django: clean up expired sessions
python manage.py clearsessions
# Run via cron daily
0 3 * * * cd /app && python manage.py clearsessions
Redis handles this automatically via TTL — expired keys are lazily or actively cleaned by Redis itself.
For monitoring active sessions (useful for security auditing):
def get_active_sessions(redis_client, prefix="session:"):
"""Count and optionally list active sessions."""
cursor = 0
count = 0
while True:
cursor, keys = redis_client.scan(cursor, match=f"{prefix}*", count=100)
count += len(keys)
if cursor == 0:
break
return count
def force_logout_user(redis_client, user_id: int, prefix="session:"):
"""Invalidate all sessions for a specific user."""
cursor = 0
destroyed = 0
while True:
cursor, keys = redis_client.scan(cursor, match=f"{prefix}*", count=100)
for key in keys:
data = json.loads(redis_client.get(key) or "{}")
if data.get("user_id") == user_id:
redis_client.delete(key)
destroyed += 1
if cursor == 0:
break
return destroyed
Cookie configuration reference
Every session cookie in production should have these attributes:
| Attribute | Value | Purpose |
|---|---|---|
| HttpOnly | True | Prevents JavaScript access (XSS protection) |
| Secure | True | Only sent over HTTPS |
| SameSite | Lax or Strict | Limits cross-site sending (CSRF reduction) |
| Domain | Omit or specific | Controls which domains receive the cookie |
| Path | / | Available across the entire site |
| Max-Age | 1800 | Session timeout in seconds |
SameSite=Lax allows the cookie on top-level navigations (clicking a link to your site) but blocks it on cross-origin POST requests and iframe loads. Strict blocks it on all cross-origin requests, including top-level navigations — which means clicking a link to your site from an email won’t include the session cookie, forcing a re-login.
The one thing to remember: Production session management in Python needs a centralized store (Redis), session ID regeneration after login, proper cookie flags (HttpOnly, Secure, SameSite), sliding expiration, and JSON serialization instead of pickle to avoid code execution vulnerabilities.
See Also
- Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
- Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
- Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
- Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
- Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.