Why Django's CSRF Protection Saved Me Before I Even Knew What CSRF Was
March 14, 2026, 12:24 p.m.
A few weeks into building my Django blog, I added a form. A simple one — just a title and a text area to create a post. I wired up the view, tested it locally, it worked.
Then I read about CSRF.
What is CSRF exactly?
CSRF stands for Cross-Site Request Forgery. The name is intimidating. The concept is not.
Here's what it means in plain terms: imagine you're logged into your bank at mybank.com. In another tab, you visit a malicious website. That website contains a hidden form that points to mybank.com/transfer/ and submits automatically when the page loads.
Your browser, being helpful, attaches your session cookie to that request. The bank sees a valid session. The transfer goes through. You never clicked anything.
That's CSRF. An attacker forges a request on your behalf, using your own credentials, from a completely different site.
In the context of my blog, the attack would look like this:
<!-- On a malicious site, invisible to the user -->
<form action="https://mydevblog.com/post/create/" method="POST">
<input type="hidden" name="title" value="Hacked" />
<input type="hidden" name="content" value="This post was created by an attacker." />
</form>
<script>document.forms[0].submit()</script>
If I'm logged into my blog in another tab, that form submits as me. A post gets created under my account. I never see it happen.
Why Django protects you by default
Django includes a middleware called CsrfViewMiddleware. It's enabled by default in every new project, inside settings.py:
MIDDLEWARE = [
...
'django.middleware.csrf.CsrfViewMiddleware',
...
]
Here's how it works: every time Django renders a form, it generates a unique random token tied to your session. That token is embedded in the form as a hidden field. When the form is submitted, Django checks that the token matches what it expects.
A malicious site can't know this token. It changes with every session. So when the forged form arrives without it — or with the wrong one — Django rejects it with a 403 Forbidden.
The attacker's form fails. Not because Django detected the malicious site. But because the token is missing.
What you have to do
One line, inside every form:
<form method="POST">
{% csrf_token %}
<input type="text" name="title" />
<button type="submit">Create post</button>
</form>
{% csrf_token %} renders a hidden input field with the token. Django's middleware checks it on the other end. That's the entire contract.
If you forget it, Django will reject your own form with a 403. Which is actually a good thing — you'd rather find out in development than in production.
When Django won't save you
Django's CSRF protection only applies to forms rendered through Django's template system. The moment you bypass that, you're on your own.
The two most common cases:
AJAX requests: if you're sending POST requests with fetch() or axios, you need to extract the token from the cookie and include it in the request headers manually.
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch('/post/create/', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'My post' }),
});
@csrf_exempt: Django provides a decorator to disable CSRF protection on a specific view. It exists for legitimate reasons — webhooks from external services, for example. But it's easy to use it as a shortcut when a form isn't working, and forget why it was added.
from django.views.decorators.csrf import csrf_exempt
# ❌ Disables all CSRF protection on this view
@csrf_exempt
def my_view(request):
...
If you have @csrf_exempt in your codebase, make sure you know exactly why it's there.
What I found in my project
I checked every form in my blog. Two things:
-
My post creation form was missing
{% csrf_token %}. It was only working in development because I was testing it with the Django test client, which bypasses CSRF by default. In production, it would have been broken — or worse, unprotected. -
I had one view marked
@csrf_exemptfrom a tutorial I had followed. I removed it.
Two lines changed. The app is safer for it.
Takeaway
CSRF is not an abstract threat. It's a real attack that works by exploiting your browser's default behaviour — attaching cookies to every request, regardless of where the request comes from.
Django stops it by requiring a secret token that only your server and your legitimate forms know. But the token only works if you put it there.
Before your next commit, check every <form method="POST"> in your templates. If {% csrf_token %} isn't on the line right after the opening tag, add it now.
The middleware is doing its job. Make sure your forms are doing theirs.
Part of a series on the OWASP Top 10 — written as I learn it, not after.