Cache Poisoning in Django with Bearer Tokens
Cache Poisoning in Django with Bearer Tokens — how this specific combination creates or exposes the vulnerability
Cache poisoning in Django occurs when an attacker causes cached responses to differ by request context, such as authorization or user identity. When Bearer tokens are used for authentication, developers sometimes rely on Django’s HTTP cache mechanisms without ensuring that cached responses are segregated by Authorization headers. If a response is cached based on URL and query parameters alone, a token-bearing request from one user might be served to another, exposing one user’s data or permissions to another.
For example, consider a Django view that caches responses with Vary: Authorization omitted. A request with a valid Bearer token for user A is cached. Later, an unauthenticated or low-privilege request to the same endpoint may receive the cached response intended for user A, effectively bypassing intended access controls. This is a BOLA/IDOR-like condition enabled by misconfigured caching rather than direct object-level authorization flaws, but the outcome is similar: one actor sees another’s data.
In this context, the Bearer token itself does not cause the vulnerability; the issue is the lack of cache-key differentiation by authorization context. If your caching strategy stores responses keyed only on path and query string, tokens that change authorization state are not considered. Additionally, if the response includes sensitive data derived from the token (such as user ID or roles), the cached representation becomes a vector for information disclosure. Common patterns include using GET endpoints that return user-specific data while caching at the CDN or proxy level without appropriate Vary rules.
Real-world analogs align with OWASP API Top 10 categories such as Broken Object Level Authorization (BOLA) and Security Misconfiguration. Unlike server-side session cookies, Bearer tokens are often treated as opaque values, but their presence must influence cache behavior. For instance, a cached response containing an access token leakage risk or PII extracted from database results can persist across users if Vary headers are not correctly set. This is especially relevant when using HTTP caches, reverse proxies, or cache-control headers that do not incorporate authorization context.
To detect such issues, scanners like middleBrick evaluate whether cached responses vary by Authorization headers and whether tokens inadvertently affect cache boundaries. The tool checks for missing Vary: Authorization and tests whether different token contexts yield distinct cache entries. Without such checks, developers may unknowingly expose user-specific data through seemingly harmless caching optimizations.
Bearer Tokens-Specific Remediation in Django — concrete code fixes
To prevent cache poisoning with Bearer tokens in Django, ensure cached responses are segmented by authorization context. Use the Vary response header to indicate that content depends on the Authorization request header. Below are concrete patterns you can apply in your views and middleware.
1. Use Vary: Authorization on authenticated endpoints
For views that rely on Bearer token authentication, explicitly declare that the response varies by the Authorization header. If you are using Django REST Framework, this can be done at the view or renderer level. For standard Django views, set the header manually or use a decorator.
from django.http import HttpResponse
from django.views import View
class UserProfileView(View):
def get(self, request):
# Assume token authentication has already run and user is set on request
data = {"user_id": request.user.id, "role": request.user.role}
response = HttpResponse(json.dumps(data), content_type="application/json")
response['Vary'] = 'Authorization'
return response
If you use Django REST Framework, a cleaner approach is a mixin or custom renderer:
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status
def vary_on_authorization(view_func):
def wrapped(request, *args, **kwargs):
response = view_func(request, *args, **kwargs)
response['Vary'] = 'Authorization'
return response
return wrapped
@vary_on_authorization
def sensitive_data(request):
return Response({"details": "user-specific"})
2. Configure caching middleware with proper key prefixes
When using Django’s cache framework, avoid storing user-specific data in a shared cache key without incorporating the user or token context. Use low-level cache APIs with a key that includes a user identifier or a hash derived from the token scope (not the raw token).
from django.core.cache import cache
import hashlib
import json
def get_user_cache_key(user_id, suffix):
key = f"user_data_{user_id}_{suffix}"
return key
def get_user_data(user_id):
cached = cache.get(get_user_cache_key(user_id, "profile"))
if cached is not None:
return cached
# Expensive operation
data = {"preferences": {"theme": "dark"}}
cache.set(get_user_cache_key(user_id, "profile"), data, timeout=300)
return data
3. Avoid caching responses that include Authorization headers
Ensure cache-control directives for endpoints that validate Bearer tokens do not encourage shared caches to store responses. Use no-store or private directives where appropriate.
from django.http import HttpResponse
import json
def api_handler(request):
if "Authorization" not in request.headers:
return HttpResponse(json.dumps({"error": "unauthorized"}), status=401)
# Process request
response = HttpResponse(json.dumps({"ok": True}), content_type="application/json")
# Private cache only, do not share across users
response['Cache-Control'] = 'private, no-store'
response['Vary'] = 'Authorization'
return response
4. Validate token scope before caching
If your API issues tokens with scopes, ensure the cache key includes scope information or that responses with different scopes are never shared. This prevents a token with broader permissions from receiving a cached response intended for a narrower context.
import json
from django.http import JsonResponse
def scoped_view(request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return JsonResponse({"error": "bad auth"}, status=401)
token = auth.split(" ", 1)[1]
scope = get_scope_from_token(token) # implement this
cache_key = f"scope_cache_{scope}"
cached = cache.get(cache_key)
if cached is not None:
return cached
# Generate and cache scoped response
response = JsonResponse({"scope": scope})
response['Vary'] = 'Authorization'
cache.set(cache_key, response, timeout=60)
return response
These practices reduce the risk that a Bearer token influences or leaks via cached responses. They complement scanning workflows that check for missing Vary rules and improper cache segregation, such as those provided by security tools including middleBrick.