Django
CsrfViewMiddleware, CSRF_TRUSTED_ORIGINS, the csrf_exempt trap, and why Django REST Framework's TokenAuthentication skips the check entirely.
The rule
Keep CsrfViewMiddleware enabled, keep @csrf_exempt out of the codebase, and understand what Django REST Framework does differently.
Django's CSRF protection is among the most complete of any framework, and it is on by default. It does three things most others do not:
- Masks the token per render to defeat BREACH.
- Checks
OriginagainstCSRF_TRUSTED_ORIGINSsince 4.0, with aRefererfallback on HTTPS. This is a real header check, not just a token. - Rejects requests with no
Refereron HTTPS whenOriginis absent, rather than failing open.
So an out-of-the-box Django application is well defended. Almost every Django CSRF finding in practice comes from something the team added: an exemption, a DRF authentication class, or a widened CSRF_TRUSTED_ORIGINS.
Configuration
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
# CsrfViewMiddleware removed "because the API doesn't need it".
# It is a global removal -- every server-rendered view lost protection
# too, which is almost never what was intended.
'django.contrib.auth.middleware.AuthenticationMiddleware',
]
# A wildcard subdomain entry. Any subdomain -- including one taken over via
# a dangling CNAME -- is now a trusted origin.
CSRF_TRUSTED_ORIGINS = ['https://*.example.com']
# Readable by JavaScript on any subdomain, and scoped to the parent domain
# so any subdomain can overwrite it.
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_DOMAIN = '.example.com'
SESSION_COOKIE_SAMESITE = None # note: the string None, not LaxTemplates and AJAX
<!-- One tag. It renders a hidden input containing a freshly-masked token.
The mask changes on every render, so two views of the same page show
different values -- that is BREACH mitigation, not a bug. -->
<form method="post" action="{% url 'change_email' %}">
{% csrf_token %}
<input type="email" name="email" required>
<button type="submit">Update</button>
</form>Django REST Framework: the important one
# This is the single most common source of real CSRF findings in Django
# applications, and it is not obvious from reading the code.
#
# DRF's SessionAuthentication ENFORCES CSRF -- it explicitly calls the check.
# But DRF's other authentication classes DO NOT, because they are designed
# for credentials that are not ambient.
#
# The bug appears when a class that skips CSRF is combined with a credential
# that IS ambient.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication', # enforces CSRF
'rest_framework.authentication.TokenAuthentication', # does NOT
],
}
# DRF tries each class in order and stops at the first that authenticates.
# That is fine as written -- TokenAuthentication uses an Authorization
# header, which is not ambient, so skipping CSRF is correct for it.
#
# The danger is a CUSTOM authentication class that reads an ambient
# credential without enforcing CSRF:
class CookieTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
# Reads a COOKIE -- ambient, sent automatically by the browser --
# but inherits no CSRF enforcement. Every endpoint using this is
# CSRF-able, and nothing in the code says so.
token = request.COOKIES.get('auth_token')
if not token:
return None
return (User.objects.get(auth_token=token), None)
# The fix: implement enforce_csrf, as SessionAuthentication does.
# def enforce_csrf(self, request):
# check = CSRFCheck(lambda r: None)
# check.process_request(request)
# reason = check.process_view(request, None, (), {})
# if reason:
# raise exceptions.PermissionDenied(f'CSRF Failed: {reason}')
# Also worth knowing: overriding SessionAuthentication to remove
# enforce_csrf() is a documented "fix" in several blog posts and Stack
# Overflow answers for making a SPA work. It disables CSRF protection
# entirely. Grep for `def enforce_csrf` returning None.The exemption trap
from django.views.decorators.csrf import csrf_exempt
# Almost always added to make something work during development -- a webhook,
# a mobile client sending the wrong header, an integration test -- and then
# never removed. Every one of these is a route with no CSRF protection.
@csrf_exempt
def webhook(request):
# If this is a genuine third-party webhook, the exemption is legitimate --
# but then it MUST authenticate by signature, not by session cookie.
process(json.loads(request.body))
return JsonResponse({'ok': True})
@csrf_exempt # <- no justification at all
def change_email(request):
request.user.email = request.POST['email']
request.user.save()
return JsonResponse({'ok': True})
# Audit with:
# grep -rn 'csrf_exempt\|ensure_csrf_cookie\|requires_csrf_token' .
#
# Every hit needs a comment saying why. A webhook exempted from CSRF and
# authenticated by an HMAC signature is correct; anything authenticated by
# a session cookie is a finding.Checklist
CsrfViewMiddlewarepresent inMIDDLEWARE, globally, not removed for an API.grep -rn csrf_exemptreturns nothing without a written justification.CSRF_TRUSTED_ORIGINScontains exact origins, no wildcards.CSRF_USE_SESSIONS = Truewhere a decoupled frontend does not need to read the token.- If a cookie is used:
CSRF_COOKIE_SECURE = True,CSRF_COOKIE_DOMAIN = None,SameSite=Lax, and a__Host-prefixed name. SESSION_COOKIE_SAMESITE = 'Lax'set explicitly rather than left to the browser.- No custom DRF authentication class reads a cookie without implementing
enforce_csrf. - No override of
SessionAuthentication.enforce_csrfthat returns without checking. - Views check
request.method— a view that acts on GET is unprotected by design, since Django exemptsGET,HEAD,OPTIONS, andTRACE. - Re-authentication required for password change, email change, and adding a payment method.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.
img, script, link, scripted link clicks, and top-level navigation — and why only the last one still carries a cookie.