Stateful, database-backed session management for Django Rest Framework with JWT access tokens, rotating refresh tokens, and comprehensive security features
Project description
drf-sessions Documentation
drf-sessions bridges the gap between stateless JWT authentication and stateful session management. Unlike pure JWT solutions, drf-sessions maintains a persistent record of each authentication session in your database, enabling instant revocation, session limits, activity tracking, and audit trails—all while leveraging the performance benefits of JWT for request authentication.
Why DRF Sessions?
Traditional JWT Problems:
- Cannot revoke tokens before expiration
- No centralized session management
- Limited user context tracking
- No per-user session limits
DRF Sessions Solutions:
- ✅ Instant session revocation
- ✅ Database-backed session lifecycle management
- ✅ Flexible context metadata storage
- ✅ Per-user session limits with FIFO eviction
- ✅ Multiple transport layers (Headers/Cookies)
- ✅ Rotating refresh tokens with optional reuse detection
- ✅ Sliding session windows
- ✅ Built-in Django Admin integration
- ✅ Easy customization and feature extensions.
Requirements
- Python 3.9+
- Django 4.2+
- Django Rest Framework 3.14+
- PyJWT 2.10.0+
- django-swapper 1.3+
- uuid6-python 2025.0.1+
Installation
pip install drf-sessions
Cryptographic Dependencies (Optional)
if you are planning on encoding or decoding jwt tokens using certain digital signature algorithms (like RSA or ECDSA), you will need to install the cryptography library. This can be installed explicitly, or as a required extra in the drf-sessions requirement:
pip install drf-sessions[crypto]
Add to your INSTALLED_APPS:
INSTALLED_APPS = [
# ...
'rest_framework',
'drf_sessions',
# ...
]
Run migrations:
python manage.py migrate
Quick Start
1. Configure Settings
Add to your settings.py:
from datetime import timedelta
DRF_SESSIONS = {
'ACCESS_TOKEN_TTL': timedelta(minutes=15),
'REFRESH_TOKEN_TTL': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'ENFORCE_SINGLE_SESSION': False,
'MAX_SESSIONS_PER_USER': 5,
}
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.BearerAuthentication',
'drf_sessions.auth.CookieAuthentication',
),
}
2. Create a Login View
from rest_framework.views import APIView
from django.contrib.auth import authenticate
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from drf_sessions.services import SessionService
class LoginView(APIView):
permission_classes = [AllowAny]
def post(self, request):
username = request.data.get('username')
password = request.data.get('password')
user = authenticate(username=username, password=password)
if not user:
return Response({'error': 'Invalid credentials'}, status=401)
# Create a new header session
issued = SessionService.create_header_session(
user=user,
context={
'ip_address': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT'),
}
)
return Response({
'access_token': issued.access_token,
'refresh_token': issued.refresh_token,
})
3. Create a Refresh View
class RefreshView(APIView):
permission_classes = [AllowAny]
def post(self, request):
refresh_token = request.data.get('refresh_token')
if not refresh_token:
return Response({'error': 'Refresh token required'}, status=400)
issued = SessionService.refresh_token(refresh_token)
if not issued:
return Response({'error': 'Invalid or expired token'}, status=401)
return Response({
'access_token': issued.access_token,
'refresh_token': issued.refresh_token,
})
4. Protected Endpoint Example
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
class ProfileView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
# request.user contains the authenticated user
# request.auth contains the session instance
return Response({
'username': request.user.username,
'session_id': str(request.auth.session_id),
'created_at': request.auth.created_at,
})
Configuration
Core Settings
All settings are configured in your Django settings.py under the DRF_SESSIONS dictionary:
DRF_SESSIONS = {
# Session Lifecycle
"ACCESS_TOKEN_TTL": timedelta(minutes=15),
"REFRESH_TOKEN_TTL": timedelta(days=7),
"SESSION_MODEL": "drf_sessions.Session",
"ENFORCE_SINGLE_SESSION": False,
"MAX_SESSIONS_PER_USER": 10,
"UPDATE_LAST_LOGIN": True,
"RETAIN_EXPIRED_SESSIONS": False,
# Sliding Window Logic
"ENABLE_SLIDING_SESSION": False,
"SLIDING_SESSION_MAX_LIFETIME": timedelta(days=30),
# Security Policy
"AUTH_COOKIE_NAMES": ("token",),
"AUTH_HEADER_TYPES": ("Bearer",),
"ENFORCE_SESSION_TRANSPORT": True,
"ROTATE_REFRESH_TOKENS": True,
"REVOKE_SESSION_ON_REUSE": True,
"REFRESH_TOKEN_HASH_ALGORITHM": "sha256",
"LEEWAY": timedelta(seconds=0),
"RAISE_ON_MISSING_CONTEXT_ATTR": False,
# JWT Configuration
"JWT_ALGORITHM": "HS256",
"JWT_SIGNING_KEY": settings.SECRET_KEY,
"JWT_VERIFYING_KEY": None,
"JWT_KEY_ID": None,
"JWT_AUDIENCE": None,
"JWT_ISSUER": None,
"JWT_JSON_ENCODER": None,
"JWT_HEADERS": {},
# Claims Mapping
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "sub",
"SESSION_ID_CLAIM": "sid",
"JTI_CLAIM": "jti",
# Extensibility Hooks (Dotted paths to callables)
"JWT_PAYLOAD_EXTENDER": None,
"SESSION_VALIDATOR_HOOK": None,
"POST_AUTHENTICATED_HOOK": None,
}
Above, the default values for these settings are shown.
Session Lifecycle
ACCESS_TOKEN_TTL
Type: timedelta
Default: timedelta(minutes=15)
How long access tokens remain valid. Short lifetimes improve security.
DRF_SESSIONS = {
'ACCESS_TOKEN_TTL': timedelta(minutes=5),
}
REFRESH_TOKEN_TTL
Type: timedelta or None
Default: timedelta(days=7)
How long refresh tokens remain valid. Must be longer than ACCESS_TOKEN_TTL.
DRF_SESSIONS = {
'REFRESH_TOKEN_TTL': timedelta(days=7),
}
ENFORCE_SINGLE_SESSION
Type: bool
Default: False
If True, only one active session per user is allowed. Creating a new session revokes all previous sessions.
DRF_SESSIONS = {
'ENFORCE_SINGLE_SESSION': True, # Force logout from other devices
}
MAX_SESSIONS_PER_USER
Type: int or None
Default: 10
Maximum number of concurrent sessions per user. Oldest sessions are removed when limit is reached (FIFO). Set to None for unlimited sessions.
DRF_SESSIONS = {
'MAX_SESSIONS_PER_USER': 3,
}
UPDATE_LAST_LOGIN
Type: bool
Default: True
Whether to update the user's last_login field when creating a session.
DRF_SESSIONS = {
'UPDATE_LAST_LOGIN': True,
}
RETAIN_EXPIRED_SESSIONS
Type: bool
Default: False
If True, expired sessions are soft-deleted (revoked) for audit purposes. If False, they are permanently deleted.
DRF_SESSIONS = {
'RETAIN_EXPIRED_SESSIONS': True, # Keep history
}
Sliding Session Window
ENABLE_SLIDING_SESSION
Type: bool
Default: False
Enable sliding session windows. When enabled, sessions extend their lifetime on each activity. Each refresh token expiry will be extended until the SLIDING_SESSION_MAX_LIFETIME set on the session instance is reached.
DRF_SESSIONS = {
'ENABLE_SLIDING_SESSION': True,
}
SLIDING_SESSION_MAX_LIFETIME
Type: timedelta or None
Default: timedelta(days=30)
Maximum lifetime for sliding sessions. Required when ENABLE_SLIDING_SESSION is True. Must be greater than REFRESH_TOKEN_TTL.
DRF_SESSIONS = {
'ENABLE_SLIDING_SESSION': True,
'SLIDING_SESSION_MAX_LIFETIME': timedelta(days=90),
}
Security Settings
ENFORCE_SESSION_TRANSPORT
Type: bool
Default: True
If True, sessions created for a specific transport (cookie/header) cannot be used with a different transport. Prevents session hijacking across transport layers.
DRF_SESSIONS = {
'ENFORCE_SESSION_TRANSPORT': True,
}
ROTATE_REFRESH_TOKENS
Type: bool
Default: True
If True, refresh tokens are one-time-use and automatically rotated on each refresh request.
DRF_SESSIONS = {
'ROTATE_REFRESH_TOKENS': True,
}
REVOKE_SESSION_ON_REUSE
Type: bool
Default: True
If True, attempting to reuse a consumed refresh token immediately revokes the entire session. Critical for detecting token theft.
DRF_SESSIONS = {
'REVOKE_SESSION_ON_REUSE': True,
}
REFRESH_TOKEN_HASH_ALGORITHM
Type: str
Default: "sha256"
Hashing algorithm for refresh tokens. Must be available in Python's hashlib.
DRF_SESSIONS = {
'REFRESH_TOKEN_HASH_ALGORITHM': 'sha256',
}
LEEWAY
Type: timedelta
Default: timedelta(seconds=0)
Clock skew tolerance for JWT validation.
DRF_SESSIONS = {
'LEEWAY': timedelta(seconds=10),
}
AUTH_HEADER_TYPES
Type: tuple or list
Default: ("Bearer",)
Accepted authorization header prefixes.
DRF_SESSIONS = {
'AUTH_HEADER_TYPES': ('Bearer', 'JWT', 'Token'),
}
AUTH_COOKIE_NAMES
Type: tuple or list
Default: ("token",)
Cookie names to check for authentication tokens.
DRF_SESSIONS = {
'AUTH_COOKIE_NAMES': ('token', 'access_token', 'auth_token'),
}
JWT Configuration
JWT_ALGORITHM
Type: str
Default: "HS256"
JWT signing algorithm. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512.
DRF_SESSIONS = {
'JWT_ALGORITHM': 'RS256',
}
JWT_SIGNING_KEY
Type: str
Default: settings.SECRET_KEY
Secret key for signing JWTs (HMAC) or private key (RSA/ECDSA).
DRF_SESSIONS = {
'JWT_SIGNING_KEY': 'your-secret-key-here',
}
JWT_VERIFYING_KEY
Type: str or None
Default: None
Public key for asymmetric algorithms (RS256, ES256, etc.). Required for asymmetric algorithms.
DRF_SESSIONS = {
'JWT_ALGORITHM': 'RS256',
'JWT_VERIFYING_KEY': """--BEGIN PUBLIC KEY--
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
--END PUBLIC KEY--""",
}
JWT_AUDIENCE
Type: str or None
Default: None
JWT audience claim (aud).
DRF_SESSIONS = {
'JWT_AUDIENCE': 'my-api',
}
JWT_ISSUER
Type: str or None
Default: None
JWT issuer claim (iss).
DRF_SESSIONS = {
'JWT_ISSUER': 'https://myapp.com',
}
JWT_KEY_ID
Type: str or None
Default: None
JWT key identifier header (kid).
DRF_SESSIONS = {
'JWT_KEY_ID': 'key-2024-01',
}
JWT_HEADERS
Type: dict
Default: {}
Additional JWT headers.
DRF_SESSIONS = {
'JWT_HEADERS': {'typ': 'JWT'},
}
Claims Mapping
USER_ID_FIELD
Type: str
Default: "id"
User model field to use as the user identifier.
DRF_SESSIONS = {
'USER_ID_FIELD': 'uuid', # If using UUID primary keys
}
USER_ID_CLAIM
Type: str
Default: "sub"
JWT claim name for user identifier.
SESSION_ID_CLAIM
Type: str
Default: "sid"
JWT claim name for session identifier.
JTI_CLAIM
Type: str
Default: "jti"
JWT claim name for JWT ID.
Extensibility Hooks
JWT_PAYLOAD_EXTENDER
Type: str (dotted path) or None
Default: None
Callable to add custom claims to JWT payload.
# myapp/auth.py
def add_custom_claims(session):
return {
'role': session.user.role,
'department': session.user.department,
}
# settings.py
DRF_SESSIONS = {
'JWT_PAYLOAD_EXTENDER': 'myapp.auth.add_custom_claims',
}
Function Signature:
def custom_extender(session: AbstractSession) -> dict:
"""
Args:
session: The session instance being encoded
Returns:
Dictionary of additional claims to include
"""
pass
SESSION_VALIDATOR_HOOK
Type: str (dotted path) or None
Default: None
Callable to validate sessions during authentication. Return False to reject.
# myapp/auth.py
def validate_ip_address(session, request):
"""Ensure IP address hasn't changed."""
stored_ip = session.context_obj.ip_address
current_ip = request.META.get('REMOTE_ADDR')
return stored_ip == current_ip
# settings.py
DRF_SESSIONS = {
'SESSION_VALIDATOR_HOOK': 'myapp.auth.validate_ip_address',
}
Function Signature:
def custom_validator(session: AbstractSession, request: Request) -> bool:
"""
Args:
session: The session being authenticated
request: The DRF request object
Returns:
True if session is valid, False to reject authentication
"""
pass
POST_AUTHENTICATED_HOOK
Type: str (dotted path) or None
Default: None
Callable executed after successful authentication. Can modify user or session.
# myapp/auth.py
def update_activity(user, session, request):
"""Update last activity timestamp."""
session.last_activity_at = timezone.now()
session.save(update_fields=['last_activity_at'])
return user, session
# settings.py
DRF_SESSIONS = {
'POST_AUTHENTICATED_HOOK': 'myapp.auth.update_activity',
}
Function Signature:
def post_auth_hook(
user: AbstractBaseUser,
session: AbstractSession,
request: Request
) -> Tuple[AbstractBaseUser, AbstractSession]:
"""
Args:
user: The authenticated user
session: The session instance
request: The DRF request object
Returns:
Tuple of (user, session) - can return modified instances
"""
pass
RAISE_ON_MISSING_CONTEXT_ATTR
Type: bool
Default: False
If True, accessing missing context attributes raises AttributeError. If False, returns None.
DRF_SESSIONS = {
'RAISE_ON_MISSING_CONTEXT_ATTR': True,
}
# With True:
session.context_obj.nonexistent # Raises AttributeError
# With False:
session.context_obj.nonexistent # Returns None
Authentication Classes
DRF Sessions provides two ready-to-use authentication classes:
BearerAuthentication
Extracts tokens from the Authorization header.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.BearerAuthentication',
),
}
Request Example:
GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
CookieAuthentication
Extracts tokens from HTTP-only cookies.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.CookieAuthentication',
),
}
Setting Cookie in Response:
response = Response({'message': 'Logged in'})
response.set_cookie(
key='token',
value=issued.access_token,
httponly=True,
secure=True,
samesite='Strict',
)
Using Both
You can combine both authentication methods:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.BearerAuthentication',
'drf_sessions.auth.CookieAuthentication',
),
}
Custom Authentication Classes
Create custom authentication by subclassing base classes:
from drf_sessions.base.auth import BaseHeaderAuthentication, BaseCookieAuthentication
class CustomHeaderAuth(BaseHeaderAuthentication):
def extract_token(self, request):
# Custom extraction logic
return request.META.get('HTTP_X_AUTH_TOKEN')
class CustomCookieAuth(BaseCookieAuthentication):
def extract_token(self, request):
# Custom extraction logic
return request.META.get('HTTP_X_AUTH_TOKEN')
Session Management
Creating Sessions
Using SessionService
The SessionService provides a high-level API for session creation:
from drf_sessions.services import SessionService
from drf_sessions.choices import AUTH_TRANSPORT
# Generic session (works with any transport)
issued = SessionService.create_session(
user=user,
context={'device': 'mobile'},
)
# Header-only session
issued = SessionService.create_header_session(
user=user,
context={'platform': 'ios'},
)
# Cookie-only session
issued = SessionService.create_cookie_session(
user=user,
context={'browser': 'chrome'},
)
Using Session Manager Directly
from drf_sessions.models import get_session_model
Session = get_session_model()
issued = Session.objects.create_session(
user=user,
transport='header',
context={'ip': request.META.get('REMOTE_ADDR')},
)
Custom TTLs
Override default token lifetimes per session:
from datetime import timedelta
issued = SessionService.create_session(
user=user,
access_ttl=timedelta(minutes=30),
refresh_ttl=timedelta(days=14),
)
Token Rotation
Refresh tokens to obtain new access tokens:
from drf_sessions.services import SessionService
# In your refresh view
refresh_token = request.data.get('refresh_token')
issued = SessionService.rotate_refresh_token(refresh_token)
if not issued:
return Response({'error': 'Invalid token'}, status=401)
return Response({
'access_token': issued.access_token,
'refresh_token': issued.refresh_token,
})
Rotation Behavior:
With ROTATE_REFRESH_TOKENS=True (default):
- Old refresh token is consumed (marked as used)
- New refresh token is generated and returned
- Attempting to reuse old token triggers reuse detection
With ROTATE_REFRESH_TOKENS=False:
- Same refresh token can be used multiple times
- Less secure but simpler for some use cases
Session Revocation
Revoke Single Session
# In a logout view
from drf_sessions.models import get_session_model
Session = get_session_model()
# Revoke current session (where auth return an instance of a session)
request.auth.revoke()
Revoke All User Sessions
# Logout from all devices
from drf_sessions.services import SessionService
SessionService.revoke_user_sessions(user)
Query Active Sessions
# Get all active sessions for a user
active_sessions = Session.objects.active().filter(user=request.user)
for session in active_sessions:
print(f"Session: {session.session_id}")
print(f"Created: {session.created_at}")
print(f"Transport: {session.transport}")
print(f"Device: {session.context_obj.user_agent}")
Context Metadata
Store arbitrary metadata with each session using the context field:
Setting Context on Creation
issued = SessionService.create_session(
user=user,
context={
'ip_address': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT'),
'device_id': request.data.get('device_id'),
'platform': 'web',
'location': 'San Francisco',
}
)
Accessing Context
Context data is available via dot notation through the context_obj property:
# In a view
session = request.auth
# Access via dot notation
ip = session.context_obj.ip_address
device = session.context_obj.device_id
platform = session.context_obj.platform
# Missing attributes return None (or raise AttributeError if configured)
missing = session.context_obj.nonexistent # None
# Raw dict access
raw_context = session.context
Context Validation
The library validates that context is always a dictionary:
# ✅ Valid
context = {'key': 'value', 'nested': {'data': 123}}
# ❌ Invalid - will raise ValidationError
context = ['list', 'not', 'allowed']
context = "string not allowed"
Best Practices
Security-Sensitive Data:
context = {
'ip_address': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT')[:200], # Truncate
'device_fingerprint': compute_fingerprint(request),
}
Session Validator Using Context:
def ip_consistency_validator(session, request):
"""Reject if IP address changed."""
original_ip = session.context_obj.ip_address
current_ip = request.META.get('REMOTE_ADDR')
return original_ip == current_ip
DRF_SESSIONS = {
'SESSION_VALIDATOR_HOOK': 'myapp.validators.ip_consistency_validator',
}
Transport Enforcement
Transport enforcement prevents session hijacking across different delivery methods.
How It Works
When ENFORCE_SESSION_TRANSPORT=True (default), sessions are bound to their creation transport:
# Session created for header transport
issued = SessionService.create_header_session(user=user)
# ✅ Works: Using Authorization header
GET /api/profile
Authorization: Bearer <token>
# ❌ Fails: Trying to use same token in cookie
GET /api/profile
Cookie: token=<same-token>
# AuthenticationFailed: This session is restricted to header transport
Transport Types
from drf_sessions.choices import AUTH_TRANSPORT
# ANY - works with both headers and cookies
AUTH_TRANSPORT.ANY # 'any'
# HEADER - only Authorization header
AUTH_TRANSPORT.HEADER # 'header'
# COOKIE - only HTTP cookies
AUTH_TRANSPORT.COOKIE # 'cookie'
Use Cases
Mobile Apps (Header-only):
issued = SessionService.create_header_session(user=user)
# Prevents token theft if attacker gains access to web session
Web Apps (Cookie-only):
issued = SessionService.create_cookie_session(user=user)
# Prevents XSS attacks from stealing tokens
Hybrid (Flexible):
issued = SessionService.create_universal_session(user=user)
# Allow same session across web and mobile
Disabling Enforcement
DRF_SESSIONS = {
'ENFORCE_SESSION_TRANSPORT': False,
}
# Sessions work with any transport, regardless of creation method
Custom Session Models
DRF Sessions uses Django Swapper to allow custom session models.
Creating a Custom Model
# myapp/models.py
from drf_sessions.base.models import AbstractSession
class CustomSession(AbstractSession):
# Add custom fields
device_name = models.CharField(max_length=100, blank=True)
is_trusted = models.BooleanField(default=False)
class Meta(AbstractSession.Meta):
"""override or define custom Meta here"""
pass
Configuring Swapper
# settings.py
DRF_SESSIONS = {
'SESSION_MODEL': 'myapp.CustomSession',
}
Migrations
python manage.py makemigrations
python manage.py migrate
Using Custom Model
from drf_sessions.models import get_session_model
Session = get_session_model() # Returns your CustomSession
# Create session with custom fields
issued = Session.objects.create_session(
user=user,
device_name='iPhone 13',
is_trusted=True,
)
# Access custom fields
session = request.auth
if session.is_trusted:
# Allow sensitive operations
pass
RefreshToken Foreign Key
The RefreshToken model automatically uses the swapped session model:
# In RefreshToken model
session = models.ForeignKey(
swapper.get_model_name('drf_sessions', 'Session'),
on_delete=models.CASCADE,
)
Advanced Usage
Sliding Sessions
Extend session lifetime on each activity (by extend refresh token until absolute expiry is reach on session instance):
DRF_SESSIONS = {
'ENABLE_SLIDING_SESSION': True,
'REFRESH_TOKEN_TTL': timedelta(days=7),
'SLIDING_SESSION_MAX_LIFETIME': timedelta(days=30),
}
How it works:
- Session created with
absolute_expiry= now + 30 days - User refreshes token after 5 days
- New refresh token expires in 7 days (capped at absolute_expiry)
- Session remains valid until absolute_expiry (30 days from creation)
Reuse Detection
Detect stolen refresh tokens:
DRF_SESSIONS = {
'ROTATE_REFRESH_TOKENS': True,
'REVOKE_SESSION_ON_REUSE': True,
}
Scenario:
- User refreshes token → gets new token A
- Attacker steals old token and tries to use it
- System detects reuse → revokes entire session
- Both user and attacker are logged out
- User must re-authenticate
Custom JWT Claims
Add custom data to access tokens:
# myapp/auth.py
def add_permissions(session):
user = session.user
return {
'permissions': list(user.get_all_permissions()),
'is_superuser': user.is_superuser,
'groups': [g.name for g in user.groups.all()],
}
# settings.py
DRF_SESSIONS = {
'JWT_PAYLOAD_EXTENDER': 'myapp.auth.add_permissions',
}
Accessing in Views:
import jwt
def my_view(request):
# Decode JWT from request (already verified by authentication)
auth_header = request.META.get('HTTP_AUTHORIZATION', '').split()
token = auth_header[1] if len(auth_header) == 2 else None
# Get claims (verification already done by DRF)
claims = jwt.decode(
token,
options={"verify_signature": False} # Already verified
)
permissions = claims.get('permissions', [])
IP Address Validation
Enforce IP consistency:
# myapp/validators.py
def validate_ip(session, request):
stored_ip = session.context_obj.ip_address
current_ip = request.META.get('REMOTE_ADDR')
if not stored_ip:
return True # No IP stored, allow
return stored_ip == current_ip
# settings.py
DRF_SESSIONS = {
'SESSION_VALIDATOR_HOOK': 'myapp.validators.validate_ip',
}
# In your login view, store IP
issued = SessionService.create_session(
user=user,
context={'ip_address': request.META.get('REMOTE_ADDR')}
)
Device Fingerprinting
# myapp/utils.py
import hashlib
def compute_fingerprint(request):
components = [
request.META.get('HTTP_USER_AGENT', ''),
request.META.get('HTTP_ACCEPT_LANGUAGE', ''),
request.META.get('HTTP_ACCEPT_ENCODING', ''),
]
raw = '|'.join(components)
return hashlib.sha256(raw.encode()).hexdigest()
# In your login view
issued = SessionService.create_session(
user=user,
context={
'fingerprint': compute_fingerprint(request),
'user_agent': request.META.get('HTTP_USER_AGENT'),
}
)
# Validator
def validate_fingerprint(session, request):
stored = session.context_obj.fingerprint
current = compute_fingerprint(request)
return stored == current
Activity Tracking
Update last activity on each request:
# myapp/middleware.py
from django.utils import timezone
class ActivityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Update session activity if authenticated
if hasattr(request, 'auth') and request.auth:
request.auth.last_activity_at = timezone.now()
request.auth.save(update_fields=['last_activity_at'])
return response
# settings.py
MIDDLEWARE = [
# ...
'myapp.middleware.ActivityMiddleware',
]
Asymmetric JWT (RS256)
# Generate keys (example using cryptography library)
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Serialize private key
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
# Serialize public key
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# settings.py
DRF_SESSIONS = {
'JWT_ALGORITHM': 'RS256',
'JWT_SIGNING_KEY': private_pem.decode('utf-8'),
'JWT_VERIFYING_KEY': public_pem.decode('utf-8'),
}
Security Considerations
Token Storage
Never store tokens in:
- localStorage (vulnerable to XSS)
- sessionStorage (vulnerable to XSS)
- Unencrypted databases
Best practices:
- Use HTTP-only cookies for web apps
- Store in secure keychain/keystore for mobile apps
- Use
secure=Trueandsamesite='Strict'for cookies
Token Lifetimes
Recommendations:
DRF_SESSIONS = {
'ACCESS_TOKEN_TTL': timedelta(minutes=15), # Short-lived
'REFRESH_TOKEN_TTL': timedelta(days=7), # Medium-lived
'SLIDING_SESSION_MAX_LIFETIME': timedelta(days=30), # Hard limit
}
Transport Security
Always use HTTPS in production:
# settings.py (production)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
Refresh Token Rotation
Always enable rotation:
DRF_SESSIONS = {
'ROTATE_REFRESH_TOKENS': True,
'REVOKE_SESSION_ON_REUSE': True,
}
Session Limits
Prevent session exhaustion attacks:
DRF_SESSIONS = {
'MAX_SESSIONS_PER_USER': 5, # Reasonable limit
}
Context Sanitization
Never store sensitive data in context:
# ❌ Bad
context = {
'password': user.password, # Never!
'credit_card': '1234-5678-9012-3456', # Never!
}
# ✅ Good
context = {
'ip_address': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT')[:200],
'device_type': 'mobile',
}
Validator Performance
Keep validators fast to avoid request latency:
# ❌ Slow - database queries
def slow_validator(session, request):
# Avoid heavy database operations
user_status = UserStatus.objects.get(user=session.user)
return user_status.is_active
# ✅ Fast - in-memory checks
def fast_validator(session, request):
# Use cached/in-memory data
return session.user.is_active
API Reference
SessionService
create_session(user, transport='any', context=None, access_ttl=None, refresh_ttl=None)
Creates a new authentication session.
Parameters:
user(User): The user to authenticatetransport(str): Transport type ('any', 'header', 'cookie')context(dict): Metadata to store with sessionaccess_ttl(timedelta): Override default access token TTLrefresh_ttl(timedelta): Override default refresh token TTL
Returns: IssuedSession(access_token, refresh_token, session)
create_header_session(user, context=None, access_ttl=None, refresh_ttl=None)
Creates a header-only session.
create_cookie_session(user, context=None, access_ttl=None, refresh_ttl=None)
Creates a cookie-only session.
create_session(user, context=None, access_ttl=None, refresh_ttl=None)
Creates a universal session.
refresh_token(raw_refresh_token)
Exchanges a refresh token for new credentials.
Parameters:
raw_refresh_token(str): The refresh token to rotate
Returns: IssuedSession or None if invalid/expired
revoke_user_sessions(user)
Revokes all of users tokens based on the configuration for expired tokens.
Parameters:
user(str): The user whose token is to be revoked.
Returns: None
SessionManager
create_session(user, transport, context=None, access_ttl=None, refresh_ttl=None, **kwargs)
Low-level session creation. See SessionService.create_session.
active()
Returns QuerySet of active (non-revoked, non-expired) sessions.
Session.objects.active()
revoke()
Revokes all sessions in the QuerySet.
Session.objects.filter(user=user).revoke()
Session Model
Properties
session_id
UUID v7 unique identifier
user
ForeignKey to User model
transport
String: 'any', 'header', or 'cookie'
context
JSONField for metadata storage
context_obj
ContextParams wrapper for dot-notation access
last_activity_at
DateTime of last token refresh
revoked_at
DateTime of revocation (None if active)
absolute_expiry
DateTime of hard expiration (None if no limit)
is_active
Boolean property: True if not revoked and not expired
Methods
__str__()
Returns: "username (session-id)"
RefreshToken Model
Properties
token_hash
SHA-256 hash of the raw token
session
ForeignKey to Session
expires_at
DateTime when token expires
consumed_at
DateTime when token was used (None if unused)
is_expired
Boolean property: True if past expires_at
ContextParams
Methods
__getattr__(name)
Dot-notation access to context data
session.context_obj.ip_address # Returns value or None
__repr__()
Returns string representation of context
IssuedSession
NamedTuple containing new session credentials.
Fields:
access_token(str): JWT access tokenrefresh_token(str | None): Refresh token (None if REFRESH_TOKEN_TTL is None)session(AbstractSession): The database session instance
Migration Guide
From Simple JWT
DRF Sessions is designed to complement or replace django-rest-framework-simplejwt.
Key Differences:
| Feature | Simple JWT | DRF Sessions | ||--|--| | Storage | Stateless | Database-backed | | Revocation | Token blacklist | Session revocation | | Session Limits | None | FIFO session limits | | Context Storage | None | JSON metadata | | Transport Binding | None | Enforced transport types | | Admin Interface | Minimal | Full-featured |
Migration Steps:
- Install DRF Sessions:
pip install drf-sessions
- Update Settings:
# Before (Simple JWT)
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
}
# After (DRF Sessions)
DRF_SESSIONS = {
'ACCESS_TOKEN_TTL': timedelta(minutes=5),
'REFRESH_TOKEN_TTL': timedelta(days=1),
}
- Update Authentication Classes:
# Before
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
}
# After
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.BearerAuthentication',
),
}
- Update Views:
# Before (Simple JWT)
from rest_framework_simplejwt.views import TokenObtainPairView
# After (DRF Sessions)
from drf_sessions.services import SessionService
class LoginView(APIView):
def post(self, request):
user = authenticate(...)
issued = SessionService.create_session(user=user)
return Response({
'access': issued.access_token,
'refresh': issued.refresh_token,
})
- Run Migrations:
python manage.py migrate drf_sessions
From Session Authentication
If migrating from DRF's built-in session authentication:
Advantages of DRF Sessions:
- No CSRF tokens needed (JWT-based)
- Works seamlessly with mobile apps
- Better horizontal scaling (stateless access tokens)
- Explicit session lifecycle management
Migration Steps:
- Dual Authentication (Transition Period):
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'drf_sessions.auth.BearerAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
}
- Create Migration Endpoint:
class MigrateSessionView(APIView):
"""Allow users to convert session auth to JWT."""
authentication_classes = [SessionAuthentication]
def post(self, request):
issued = SessionService.create_session(user=request.user)
return Response({
'access_token': issued.access_token,
'refresh_token': issued.refresh_token,
})
- Update Frontend:
- Store tokens in secure storage
- Add Authorization header to requests
- Implement token refresh logic
- Remove Old Authentication: Once all clients migrated, remove SessionAuthentication.
Troubleshooting
Common Issues
"Invalid access token"
Cause: Token expired or signature invalid
Solutions:
- Check
ACCESS_TOKEN_TTLsetting - Verify
JWT_SIGNING_KEYhasn't changed - Implement token refresh flow
"Session is invalid or has been revoked"
Cause: Session deleted or explicitly revoked
Solutions:
- Check session still exists in database
- Verify
revoked_atis None - Check
absolute_expiryhasn't passed
"Token missing session identifier"
Cause: JWT doesn't contain session ID claim
Solutions:
- Verify token was created by DRF Sessions
- Check
SESSION_ID_CLAIMsetting matches token
Import Error: "Cannot import name 'Session'"
Cause: Swapper configuration issue
Solutions:
# Use get_session_model() instead of direct import
from drf_sessions.models import get_session_model
Session = get_session_model()
"This session is restricted to X transport"
Cause: Transport enforcement preventing cross-transport usage
Solutions:
- Use correct authentication class for session type
- Or set
ENFORCE_SESSION_TRANSPORT=False - Or create universal sessions with
create_universal_session()
Performance Optimization
Database Queries
Add select_related for better query performance:
session = Session.objects.select_related('user').get(session_id=sid)
python manage.py migrate drf_sessions
Cleanup Old Sessions
Create periodic task to delete expired sessions:
from django.utils import timezone
from drf_sessions.models import get_session_model
from drf_sessions.services import SessionService
Session = get_session_model()
# Delete all user tokens
SessionService.revoke_user_sessions(user)
# Delete expired sessions
Session.objects.filter(
absolute_expiry__lt=timezone.now()
).delete()
# Or revoke instead of delete
Session.objects.filter(
absolute_expiry__lt=timezone.now(),
revoked_at__isnull=True
).revoke()
Contributing
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Setup
git clone https://github.com/idenyigabriel/drf-sessions.git
cd drf-sessions
pip install -e ".[dev]"
python manage.py migrate
python manage.py test
Acknowledgments
- Inspired by django-rest-framework-simplejwt
- Built on Django Rest Framework
- Uses PyJWT for JWT handling
- UUID v7 support via uuid6-python
Support
- Issues: GitHub Issues
- Documentation: Read the Docs
- Discussions: GitHub Discussions
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file drf_sessions-0.1.2.tar.gz.
File metadata
- Download URL: drf_sessions-0.1.2.tar.gz
- Upload date:
- Size: 52.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa6784cdf3deeabb1c6e911fdeb530115750c9414f24265ba6d040f7e9e859c5
|
|
| MD5 |
72a861bd2f93c735c27ebe1703154d2a
|
|
| BLAKE2b-256 |
1961a74704b9a07032a77896471d9dfa2f7b546a146914a38fa3ded76578c4b9
|
File details
Details for the file drf_sessions-0.1.2-py3-none-any.whl.
File metadata
- Download URL: drf_sessions-0.1.2-py3-none-any.whl
- Upload date:
- Size: 37.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bba604131be97df9a808ddaa935eb3a61bd34487cc0d7463f5ba549bb2a7c851
|
|
| MD5 |
bf4b1d15ea60290f7486d2389a46f990
|
|
| BLAKE2b-256 |
1cc5c640362a27b974ccbb0e0ee13409fdbafc6a8fe987a8489f5a7bc1584cac
|