Cross-Site Request Forgery (CSRF): How Your Browser Betrays You — and How Django's Token Stops It
Django Security Series — Post 8 | Series II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Reading time: ~15 min
The first two posts of Series II dealt with an attacker who already had their own account and was poking at the boundaries — reading another user's objects (Post 6) or writing their own role (Post 7). In both cases the attacker made the request themselves, from their own browser, with their own session. Post 8 flips that completely. The attacker never touches your application directly. They don't need a session, a password, or an account. Instead, they trick the victim's browser into making the request for them — and the browser, dutifully obeying its programming, attaches the victim's session cookie to a request the victim never intended to make.
CSRF is the attack that made me rethink what "authenticated" actually means. My first real encounter with it was years ago, the first time I built anything with Django. I had wired up a form, the POST was submitting, and Django kept returning 403. I spent twenty minutes checking view logic before realising the template was missing {% csrf_token %}. That was the moment the middleware became visible to me — an invisible wall I had not known was there. By the time I built Petição Brasil the token was muscle memory, but it was only while researching this post that I understood the full mechanics of why Django forces you to trip over it. The 403 is annoying; the alternative (silently accepting every cross-origin POST) is worse in ways that are not obvious until you see the attack.
Cross-Site Request Forgery is not about stealing credentials. The attacker never learns the victim's password or session ID. Instead, they exploit a fundamental browser behaviour to make the victim's own browser submit a request the victim never intended. The mechanics are in the next section; the short version is that the server cannot tell the difference between a request the user meant to send and one their browser was tricked into sending.
CSRF is classified under A01 (Broken Access Control) in OWASP 2021 because the server accepts a state-changing request it should have rejected. Django's answer is CsrfViewMiddleware, which ships enabled by default. The protection is on from the start, so the entire CSRF surface in a Django application is the set of places where a developer turned it off.
The Attack: What It Is and How It Works
CSRF exploits the trust a web application places in the user's browser. The mechanics rest on a single browser behaviour: when the browser sends a request to a domain, it automatically attaches all cookies for that domain — including session cookies — regardless of which site initiated the request. This is by design; cookies are domain-scoped, not origin-scoped. The browser cannot distinguish "the user clicked a button on bank.com" from "a script on evil.com generated a form submission to bank.com."
Here is what happens. The victim logs into bank.com and gets a session cookie. Later (maybe minutes later, maybe hours) they visit a page the attacker controls, a forum post, a link in an email, a compromised ad network. That page contains a hidden form targeting POST bank.com/transfer/. The moment the page loads, JavaScript submits the form. The browser, doing exactly what browsers do, attaches the bank.com session cookie to the request. The server receives a POST with a valid session and a valid request body. It has no mechanism to distinguish this from a request the user submitted intentionally. The transfer executes.
The attacker never sees the session cookie, never reads the response, and never needs an account on the target application. They only need the victim to visit a page they control while logged into the target. Most users stay logged into their web applications for hours or days, so this condition is almost always met.
The most dangerous delivery mechanism is a hidden HTML form that auto-submits on page load. It sends a POST (which is what state-changing endpoints should require), the browser submits it as application/x-www-form-urlencoded (a "simple" content type that does not trigger a CORS preflight), and the victim never sees it happen. Image tags (<img src="https://bank.com/transfer?to=attacker">) also work, but only against endpoints that perform state changes on GET, which should not exist. JavaScript can also send cross-origin POSTs via fetch, though non-simple content types trigger a preflight that blocks the request. The attacker crafts the form with the exact field names the target endpoint expects:
<body onload="document.getElementById('csrf-form').submit();">
<form id="csrf-form" action="https://bank.com/transfer/" method="POST">
<input type="hidden" name="to_account" value="attacker-iban" />
<input type="hidden" name="amount" value="10000" />
</form>
</body>
When the victim visits this page, the form submits instantly. The browser attaches the bank.com session cookie. The server sees a POST to /transfer/ with a valid session and processes it. The transfer completes before the victim realises anything happened.
How Attackers Exploit It
The practical exploitation of CSRF requires knowing the target endpoint's URL and expected parameters — which is often trivial. The attacker inspects the application's forms (which are visible to anyone with an account), reads the API documentation (if public), or simply reads the open-source code. They then construct the hidden form and host it on any domain they control.
The attacker does not need the victim to click a link — embedding the payload in an <iframe> on a page the victim is likely to visit (a forum, a social media platform, a compromised blog) is enough. The attack has no visible indicator to the victim: the form submission happens in the background, the page may redirect immediately to something innocuous, and the victim's browser shows no warning.
MITRE ATT&CK maps the result as T1565.001 — Data Manipulation: Stored Data Manipulation (the state change alters stored data on the target application) or, when the target is account settings specifically, T1098 — Account Manipulation (the attacker modifies credentials or account configuration using the victim's session). The delivery side does not map cleanly to ATT&CK's initial-access tactics, which assume endpoint compromise; CSRF abuses an authenticated session, not the user's system.
Real-World Incidents
Netflix CSRF Account Takeover (2006)
In October 2006, security researcher Dave Ferguson published a Full Disclosure advisory demonstrating CSRF vulnerabilities across multiple Netflix account-management endpoints. A malicious page could host hidden forms that POSTed to Netflix's settings endpoints, changing the victim's login credentials, shipping address, DVD queue, and account name. The browser attached the session cookie automatically, no anti-CSRF token was required, and the requests succeeded. The attacker never needed the victim's password — only that the victim visit the attacker's page while logged into Netflix.
Given Netflix's long-lived sessions and the prevalence of tabbed browsing, that condition was almost guaranteed. Jeremiah Grossman subsequently popularized the disclosure, calling CSRF "the sleeping giant" of web vulnerabilities — a characterization that stuck because the attack class had been known for years but largely ignored by the industry. The Netflix advisory became one of the first high-profile demonstrations that CSRF was not theoretical but a practical, exploitable attack class against major web applications.
The lesson: CSRF turns every state-changing endpoint into a remote-control surface. The attacker does not break authentication; they borrow it. The application's own session management, designed to keep the user logged in for convenience, becomes the attack vector.
Source: Dave Ferguson — Netflix CSRF Advisory (Full Disclosure, October 2006)
Django's Default Protections
Django is one of the few web frameworks that ships CSRF protection enabled by default. The protection is the CsrfViewMiddleware — added to the MIDDLEWARE list in every new project generated by django-admin startproject — and the {% csrf_token %} template tag. Together, they implement a masked Double Submit Cookie pattern (the secret lives in a cookie, and the form submits a masked copy of it; Django only uses true server-side/session storage when CSRF_USE_SESSIONS = True):
- When a page renders
{% csrf_token %}(or any code callsget_token()), the middleware sets a CSRF cookie (csrftoken) containing a random secret. This cookie isHttpOnly=Falseby default (JavaScript needs to read it for AJAX requests) and carriesSameSite=Lax, which means the browser will not send it on cross-site POST requests — so even if the token comparison somehow failed, the cookie itself would be absent from the attacker's forged request. - On every unsafe request (POST, PUT, PATCH, DELETE), the middleware demands a matching token — either as a form field named
csrfmiddlewaretoken(rendered by{% csrf_token %}) or as an HTTP headerX-CSRFToken. The middleware unmasks the submitted token and compares the underlying secret against the cookie's secret (the masking is a BREACH mitigation — the two values are not byte-identical). - If the token is missing or the secrets do not match, the middleware returns
403 Forbidden— the request never reaches the view.
This means that an attacker's hidden form on evil.com cannot produce a valid CSRF token for bank.com. The attacker can make the browser send the cookie (browsers do that automatically), but cannot read the cookie's value from a different origin (the same-origin policy prevents it), and therefore cannot include the matching form field. The token proves the request came from a page on the same origin.
One thing that surprised me: Django's session cookie has HttpOnly=True by default, which blocks JavaScript from reading it. The CSRF cookie does not. CSRF_COOKIE_HTTPONLY defaults to False.
Why? Because of how AJAX requests work. When JavaScript sends a POST via fetch, it cannot submit a form field, so it needs another way to prove the request is legitimate. The solution: JavaScript reads the CSRF cookie's value from document.cookie, then includes that value as an X-CSRFToken HTTP header. The middleware accepts either the form field or this header. But if the cookie were HttpOnly, JavaScript could not read it, and this whole AJAX workflow would break.
The alternative is to read the token from the hidden <input> that {% csrf_token %} renders in the page HTML, instead of reading it from the cookie. If your project does that (mine does), then CSRF_COOKIE_HTTPONLY = True is safe to enable and strictly better: it means that if an attacker finds an XSS vulnerability, they cannot use it to steal the CSRF token from the cookie. I only noticed the default was False when I checked my production settings while writing this post.
What Django protects automatically:
- Every POST, PUT, PATCH, and DELETE request processed by a view behind CsrfViewMiddleware requires a valid token.
- Django's own LoginView, LogoutView, PasswordResetView, and the admin interface all include {% csrf_token %} in their forms.
- The session key is rotated on login (login() calls cycle_key() for session-fixation prevention) and the CSRF token is also rotated (rotate_token()), so a pre-authentication CSRF attack cannot reuse a token captured before login.
What Django does NOT protect automatically:
- Views decorated with @csrf_exempt — the middleware is explicitly skipped. This is the single largest CSRF surface in Django applications.
- State-changing GET requests — CsrfViewMiddleware only checks "unsafe" methods (POST, PUT, PATCH, DELETE). A view that transfers money on GET /transfer/?to=attacker&amount=1000 is completely unprotected, and a simple <img> tag is enough to trigger it.
- Plain Django views that handle dual authentication manually — if you write a function view that checks both session cookies and Authorization headers without DRF, and apply @csrf_exempt to avoid 403s for token-authenticated clients, you have genuinely removed CSRF protection from the session path. DRF handles this correctly (its SessionAuthentication enforces CSRF internally regardless of the middleware decorator), but plain views do not have that safety net.
- AJAX requests without the token header — JavaScript making a fetch POST to a Django endpoint must include the X-CSRFToken header (read from the csrftoken cookie via document.cookie or Django's {% csrf_token %} tag). If the developer forgets this — or disables the CSRF cookie to "fix" the CORS error they are seeing — the protection is gone.
Vulnerable Pattern: What NOT to Do
Pattern 1 — @csrf_exempt on a state-changing view
# INSECURE — CSRF protection disabled entirely
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.contrib import messages
@csrf_exempt # ← the entire vulnerability
@login_required
def transfer_funds(request):
if request.method == 'POST':
to_account = request.POST.get('to_account')
amount = request.POST.get('amount')
# ... process transfer ...
messages.success(request, f'Transferred ${amount} to {to_account}.')
return redirect('dashboard')
return render(request, 'banking/transfer.html')
The @csrf_exempt decorator is the entire vulnerability. It tells CsrfViewMiddleware to skip the token check. An attacker hosts a hidden form that POSTs to /transfer/ with the right field names; the victim's browser sends their session cookie; the middleware, told to stand down, lets it through. Notice that @login_required is still there. It confirms the session is valid. It just has no way to ask "did you mean to send this?"
Pattern 2 — A state-changing GET endpoint
# INSECURE — state change on GET; CSRF middleware does not check GET requests
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
@login_required
def delete_account(request):
request.user.delete()
return redirect('home')
# urls.py
urlpatterns = [
path('delete-account/', views.delete_account, name='delete_account'),
]
This is the one that does not need a form, does not need JavaScript, does not need anything except a single HTML tag. An attacker puts <img src="https://target.com/delete-account/"> on a forum post. The victim's browser tries to load the "image," which means it sends a GET to that URL with the session cookie attached. The account is deleted. The CSRF middleware never even looked at the request because GET is supposed to be safe and idempotent per HTTP semantics. The entire vulnerability is performing a destructive action on a method the middleware deliberately ignores.
Pattern 3 — Missing {% csrf_token %} in a form template
<form method="post" action="/settings/update/">
<input type="text" name="email" value="{{ user.email }}">
<button type="submit">Update Email</button>
</form>
What goes wrong: This is not a vulnerability that an attacker can exploit — it is a broken form that will always return 403 Forbidden because the middleware expects a token and the form does not include one. The developer's fix, unfortunately, is often @csrf_exempt on the view rather than adding the token to the template — which converts a broken form into a CSRF vulnerability.
Pattern 4 — @csrf_exempt on a plain view that handles dual authentication
# INSECURE — csrf_exempt removes all CSRF protection from a view that accepts session auth
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
import json
@csrf_exempt # ← added "because token-auth clients don't have a CSRF cookie"
def transfer_funds(request):
"""Accepts both session auth (browser) and token auth (mobile app)."""
# Manual token check for mobile clients
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Token '):
# ... validate token, set request.user ...
pass
elif not request.user.is_authenticated:
return JsonResponse({'error': 'Not authenticated'}, status=401)
if request.method == 'POST':
data = json.loads(request.body)
# ... process transfer ...
return JsonResponse({'status': 'ok'})
The developer needed this endpoint to work for both browser users (session cookies) and mobile clients (token in the Authorization header). Token-auth clients don't carry a CSRF cookie, so their requests would fail the middleware check. The developer's shortcut: @csrf_exempt on the whole view. But this genuinely strips CSRF protection from the session path too. An attacker's hidden form triggers a session-authenticated POST, the middleware is told to stand down, and no other layer enforces CSRF — unlike DRF, a plain Django view has no internal per-auth-class enforcement. The transfer goes through.
The right fix is to use DRF, which solves this problem at the framework level (see Rule 6). If you must stay with plain views, enforce CSRF manually for session-authenticated requests and skip it only when a valid token is present.
Secure Implementation: The Django Way
Rule 1 — Never remove CsrfViewMiddleware; never use @csrf_exempt on state-changing views
The middleware is the foundation. If it is in MIDDLEWARE (it is by default) and no decorator overrides it, every state-changing request is protected. The secure baseline is simply not touching it:
# settings.py — CsrfViewMiddleware must be present (it is by default)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
# ...
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', # ← never remove this
'django.contrib.auth.middleware.AuthenticationMiddleware',
# ...
]
If you are tempted to use @csrf_exempt, stop and diagnose the actual problem. In nearly every case the fix is one of:
- Form missing {% csrf_token %} — add the tag, not the exemption.
- AJAX POST missing the token header — read the cookie and set the X-CSRFToken header (Django's docs provide a ready-made getCookie('csrftoken') snippet).
- API endpoint that accepts both session and token auth — let DRF's SessionAuthentication handle CSRF enforcement; do not override it with @csrf_exempt.
Rule 2 — Include {% csrf_token %} in every POST form
Every HTML form that submits via POST must include the token:
<form method="post" action="/settings/update/">
{% csrf_token %}
<input type="text" name="email" value="{{ user.email }}">
<button type="submit">Update Email</button>
</form>
The {% csrf_token %} tag renders a hidden <input> with name="csrfmiddlewaretoken" and a value that matches the CSRF cookie. The middleware compares the two and allows the request.
Rule 3 — Never perform state changes on GET
HTTP semantics mandate that GET requests are safe and idempotent. Enforce this in your URL routing:
# SECURE — the view only accepts POST; GET returns a confirmation page
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
@login_required
def delete_account(request):
if request.method == 'GET':
return render(request, 'accounts/confirm_delete.html')
# This view should never be reached with GET for deletion
raise MethodNotAllowed('GET')
@require_POST
@login_required
def delete_account_confirm(request):
request.user.delete()
return redirect('home')
Or, more idiomatically with a class-based view:
# SECURE — DeleteView only processes POST/DELETE
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views import View
from django.shortcuts import render, redirect
class DeleteAccountView(LoginRequiredMixin, View):
def get(self, request):
return render(request, 'accounts/confirm_delete.html')
def post(self, request):
request.user.delete()
return redirect('home')
The CSRF middleware protects the POST; an attacker cannot trigger it without a valid token.
Rule 4 — Set CSRF_COOKIE_SECURE, CSRF_COOKIE_HTTPONLY, and SameSite
Harden the CSRF cookie itself so it cannot be leaked or overwritten:
# settings.py — production CSRF cookie hardening
CSRF_COOKIE_SECURE = True # only sent over HTTPS
CSRF_COOKIE_HTTPONLY = True # JavaScript cannot read it (use the form token instead)
CSRF_COOKIE_SAMESITE = 'Lax' # not sent on cross-site POST (defence in depth)
A note on CSRF_COOKIE_HTTPONLY: setting this to True means JavaScript cannot read the cookie via document.cookie. If your AJAX code reads the cookie to set the X-CSRFToken header, you have two options:
1. Read the token from the DOM instead — the {% csrf_token %} tag renders it as a hidden input.
2. Use CSRF_USE_SESSIONS = True — the CSRF secret is stored in the session instead of a cookie, and the token is always delivered via the form tag.
Rule 5 — Configure CSRF_TRUSTED_ORIGINS correctly
When your application is behind a proxy or CDN (Heroku, Cloudflare, AWS ALB), Django's referer check needs to know which origins to trust:
# SECURE — explicit origin allowlist
CSRF_TRUSTED_ORIGINS = [
'https://myapp.com',
'https://www.myapp.com',
]
Never use a wildcard that is broader than necessary. 'https://*.herokuapp.com' trusts every Heroku app — including one an attacker controls. If possible, restrict to your exact domain.
Rule 6 — For dual-auth endpoints, use DRF (don't hand-roll it in plain views)
DRF solves the dual-auth CSRF problem at the framework level. Its APIView internally marks all views as csrf_exempt (bypassing the middleware), then SessionAuthentication re-enforces CSRF during authentication for session-based requests. Token-authenticated requests skip CSRF automatically. This means a DRF view with both SessionAuthentication and TokenAuthentication does the right thing out of the box — no decorator needed, no manual logic:
# SECURE — DRF handles CSRF enforcement per authentication type internally
from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.permissions import IsAuthenticated
class TransferView(APIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
# Session-authenticated requests: CSRF enforced by SessionAuthentication.
# Token-authenticated requests: CSRF skipped (the token IS the proof).
# DRF handles this internally — no @csrf_exempt, no manual checks.
pass
If you are tempted to build a dual-auth endpoint as a plain Django function view with @csrf_exempt, that is the signal to switch to DRF instead. The framework eliminates the class of mistake that Pattern 4 demonstrates.
CSRF Prevention Checklist
| Control | What it covers |
|---|---|
CsrfViewMiddleware in MIDDLEWARE (default) |
Automatic token validation on every unsafe request — the primary defence |
{% csrf_token %} in every POST form |
Provides the masked token the middleware validates against the cookie secret |
No @csrf_exempt on state-changing views |
Prevents developers from removing the protection per-view |
| No state changes on GET | Eliminates the attack surface that CSRF middleware does not cover |
CSRF_COOKIE_SECURE = True |
Prevents CSRF cookie leakage over plain HTTP |
CSRF_COOKIE_SAMESITE = 'Lax' |
Browser-level defence-in-depth — cross-site POST does not carry the cookie |
Explicit CSRF_TRUSTED_ORIGINS |
Prevents referer-based bypass on proxy/CDN deployments |
| DRF for dual-auth endpoints | DRF's SessionAuthentication enforces CSRF internally; plain views do not |
Testing Your Defence
Unit Tests
# tests/test_csrf.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
class CSRFProtectionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
'alice', email='alice@example.com', password='testpass123'
)
self.client = Client(enforce_csrf_checks=True) # ← critical
self.client.login(username='alice', password='testpass123')
def test_post_without_csrf_token_returns_403(self):
"""A POST without a CSRF token must be rejected."""
response = self.client.post('/settings/update/', {
'email': 'attacker@evil.com',
})
self.assertEqual(response.status_code, 403)
def test_post_with_valid_csrf_token_succeeds(self):
"""A POST with a valid CSRF token must be accepted."""
# GET the form page first to obtain the CSRF cookie
get_response = self.client.get('/settings/update/')
csrf_token = get_response.cookies.get('csrftoken')
self.assertIsNotNone(csrf_token)
response = self.client.post('/settings/update/', {
'email': 'alice-new@example.com',
'csrfmiddlewaretoken': csrf_token.value,
})
self.assertIn(response.status_code, [200, 302]) # success or redirect
def test_state_changing_endpoint_rejects_get(self):
"""State-changing endpoints must not accept GET requests."""
response = self.client.get('/accounts/delete/')
# Should return the confirmation page, NOT perform the deletion
self.assertTrue(User.objects.filter(username='alice').exists())
Scanning for @csrf_exempt
# Find every use of csrf_exempt in the project
grep -rn "csrf_exempt" --include="*.py" .
# Expected output for a secure project: zero matches outside test files
# Any match in views.py or urls.py is a finding that needs justification
Manual Verification
# Attempt a CSRF attack against a state-changing endpoint
# Step 1: Get the session cookie by logging in
curl -c cookies.txt -X POST https://staging.example.com/accounts/login/ \
-d "username=alice&password=testpass123&csrfmiddlewaretoken=$(curl -s -c - https://staging.example.com/accounts/login/ | grep csrftoken | awk '{print $7}')"
# Step 2: POST without the CSRF token — should return 403
curl -b cookies.txt -X POST https://staging.example.com/settings/update/ \
-d "email=attacker@evil.com"
# Expected: 403 Forbidden
# Step 3: Verify the response contains Django's CSRF failure page
# The body will contain "Forbidden (403)" and "CSRF verification failed"
Checking Cookie Attributes
# Verify CSRF cookie flags in production
curl -I https://yoursite.com/ 2>/dev/null | grep -i set-cookie
# Expected (production):
# Set-Cookie: csrftoken=...; ... Secure; SameSite=Lax
# If CSRF_COOKIE_HTTPONLY=True: also HttpOnly
What I Found in My Projects
The finding that actually made me pause was not a vulnerability. It was a design decision I had already shipped.
Petição Brasil's petition_share view increments a share counter on GET:
def petition_share(request, uuid):
petition = get_object_or_404(Petition, uuid=uuid)
petition.increment_share_count() # state change on GET
# ...
return JsonResponse({...})
That is a state-changing GET, exactly the pattern this post warns against. The impact is low (an attacker can inflate a share counter, not steal data), and converting it to POST would complicate the sharing workflow because the frontend triggers it from social-media link clicks. I shipped it knowingly. But writing this post made me uncomfortable with that decision in a way I was not before, because now I can see the exact shape of the vulnerability class it belongs to. It stays on my refactoring list with a note: "convert to POST if the share count ever feeds a ranking or visibility algorithm."
The rest of the audit was clean. Zero @csrf_exempt decorators anywhere in the codebase. All twelve <form method="post"> instances across every template (registration, login, password reset, petition creation, petition deletion, signature submission, petition flagging, both logout forms in the base template) include {% csrf_token %}. The flagging endpoint even applies @csrf_protect on top of the middleware:
@require_http_methods(["POST"])
@csrf_protect
def flag_petition(request, uuid):
"""Handle petition flagging/reporting."""
Production cookie settings are hardened (CSRF_COOKIE_SECURE = True, CSRF_COOKIE_HTTPONLY = True, CSRF_COOKIE_SAMESITE = 'Lax'). The one thing I am not fully satisfied with is CSRF_TRUSTED_ORIGINS including https://*.herokuapp.com, which trusts every Heroku app on the platform. It is there for review apps and staging, but it is broader than I would like. Narrowing it to the exact app name is on the list.
The lesson from CSRF is simpler than the attack mechanics suggest: Django already solved this problem, and the only way to create the vulnerability is to actively undo the solution. Leave CsrfViewMiddleware alone, put {% csrf_token %} in every form, and treat any urge to reach for @csrf_exempt as a signal that the real problem is somewhere else.
Post 9 continues Series II with Path Traversal, where the attacker escapes the intended directory on the filesystem and turns a download endpoint into a reader of settings.py or /etc/passwd.
Further Reading
- Django Docs — Cross Site Request Forgery Protection
- Django Docs — How to Use Django's CSRF Protection
- DRF Docs — SessionAuthentication and CSRF
- OWASP A01:2021 — Broken Access Control
- OWASP Cheat Sheet — Cross-Site Request Forgery Prevention
- PortSwigger Web Security Academy — Cross-Site Request Forgery (CSRF)
- MITRE ATT&CK — T1565.001: Data Manipulation: Stored Data Manipulation
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 8: Cross-Site Request Forgery