Privilege Escalation in Django with Bearer Tokens
Privilege Escalation in Django with Bearer Tokens — how this specific combination creates or exposes the vulnerability
Django’s default authentication stack is built around session cookies and user-centric models. When Bearer Tokens are introduced—commonly for APIs, mobile clients, or Single-Page Applications—the framework’s assumptions about authentication boundaries can break if token handling is inconsistent. Privilege Escalation in this context occurs when a token issued with limited scopes or a low-privilege identity is accepted as proof of a higher-privilege identity, or when endpoints that should enforce strict authorization fail to validate token ownership and permissions.
One common root cause is incomplete scope or role validation. A token might contain a scope like read:reports, but the view relies on Django’s user.is_staff or a group check without cross-referencing the token’s actual claims. If the token is issued by an external identity provider or an internal token service, and Django does not validate issuer, audience, or expiration rigorously, an attacker can reuse an old or low-privilege token and assume elevated permissions if the backend only checks presence rather than correctness.
Another vector is endpoint misalignment with token-bound authorization. Consider an API that uses path-based identifiers (e.g., /api/reports/{report_id}) and relies on the authenticated user’s ID from the token. If the view loads a report by ID and then checks whether the current user’s ID matches the report’s owner, but fails to ensure the token’s subject matches the Django user model, an attacker can manipulate the URL to access another user’s report while presenting a valid but low-privilege token. This is a Broken Level of Authorization (BOLA) pattern that can be triggered through Bearer Tokens when ownership checks are incomplete.
Middleware and permission class interactions can also contribute. Django REST Framework (DRF) introduces permission classes and authentication classes; if Bearer token authentication is implemented as a custom authentication class that sets request.user but does not consistently propagate role or scope metadata into permissions, views may default to permissive behavior. For example, using IsAuthenticated without a complementary scope check can allow a token intended for read-only operations to perform write actions if the view does not explicitly inspect scopes.
Token leakage and storage further amplify escalation risks. If Bearer Tokens are logged, cached improperly, or transmitted over non-TLS channels, an attacker can capture a low-privilege token and attempt to elevate by exploiting missing binding between token usage context and backend validation. Without runtime checks tying token usage to the expected client, IP, or nonce patterns, the API surface remains vulnerable to token replay and substitution attacks that facilitate privilege escalation.
Bearer Tokens-Specific Remediation in Django — concrete code fixes
Remediation centers on strict validation of token claims, consistent identity mapping, and explicit scope/role enforcement at the view or permission layer. Below are concrete, realistic code examples for Django and Django REST Framework that demonstrate secure handling of Bearer Tokens.
1. Validate issuer, audience, and scopes in authentication
Use a library such as PyJWT to decode and validate token claims before establishing request.user. Never trust the payload alone; enforce expected issuer and audience values.
import jwt
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponseForbidden
def jwt_bearer_token_authentication(get_response):
def middleware(request):
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
try:
# Validate signature, issuer, audience, and expiry
decoded = jwt.decode(
token,
key=settings.JWT_PUBLIC_KEY,
algorithms=['RS256'],
issuer='https://auth.example.com/',
audience='middlebrick-api',
options={'require': ['exp', 'iss', 'aud', 'scope', 'sub']}
)
# Map token subject to a Django user (simplified)
user = User.objects.filter(external_subject=decoded['sub']).first()
if user is None:
user = AnonymousUser()
# Attach scopes for later checks
request.scopes = decoded.get('scope', '').split()
request.user = user
except jwt.PyJWTError:
return HttpResponseForbidden('Invalid token')
return get_response(request)
return middleware
2. Enforce scope-based permissions in DRF
Define a custom permission that checks both user-level rights and token scopes. This prevents escalation where a token lacks required scope even if the user is authenticated.
from rest_framework.permissions import BasePermission
class ScopeRequired(BasePermission):
def __init__(self, required_scope):
self.required_scope = required_scope
def has_permission(self, request, view):
# Ensure scopes are populated by authentication middleware
if not hasattr(request, 'scopes'):
return False
return self.required_scope in request.scopes
class ReportView(APIView):
permission_classes = [IsAuthenticated, ScopeRequired]
def get(self, request, report_id):
# Ownership check using request.user, not token subject alone
report = get_object_or_404(Report, pk=report_id)
if report.owner_id != request.user.id:
raise PermissionDenied('Not owner')
return Response({'data': report.data})
3. Bind token usage to request context
When handling sensitive operations, bind token usage to additional request context such as IP or a nonce stored server-side. This reduces the impact of token leakage.
from django.core.cache import cache
def validate_token_binding(request, token_sub):
cache_key = f'token_nonce:{token_sub}'
expected_nonce = cache.get(cache_key)
if expected_nonce != request.META.get('HTTP_X_TOKEN_NONCE'):
return False
return True
4. Centralize authorization checks at the view or service layer
Do not rely on object-level permissions alone; ensure every data access path validates that the token’s subject maps correctly to the resource owner and that the token includes necessary scope for the action.
def get_ordered_report(request, report_id):
report = get_object_or_404(Report, pk=report_id)
if not request.user.has_perm('view_report', report):
raise PermissionDenied
# scope check for sensitive operations
if 'write:reports' not in getattr(request, 'scopes', []):
raise PermissionDenied
return report