CSRF & Security
Authentication and permissions control who can act and what they're allowed to do — but there's a separate class of attack that doesn't try to guess a password or escalate a permission at all. It tricks an already logged-in user's own browser into submitting a request they never meant to send. This lesson covers that attack — Cross-Site Request Forgery, or CSRF — and rounds out with a handful of other security defaults Django gives you automatically.
What CSRF Actually Is
Imagine a TaskFlow user is logged in, with their session cookie sitting in their browser. They then
visit an unrelated, malicious website in another tab. That malicious page contains a hidden form
that auto-submits a POST request to taskflow.example.com/tasks/7/delete/ the instant
it loads. Because the browser attaches cookies to requests based on the destination domain — not
which tab the request originated from — that request arrives at TaskFlow looking exactly like a
normal, logged-in request from that user. The user never clicked "delete," never even knew it
happened, but the request looks completely legitimate from TaskFlow's side. That's a CSRF attack:
trust in the user's session, exploited by a different site.
Django's Built-in CSRF Protection
Django defends against this by default, through CsrfViewMiddleware — one of several
middleware classes enabled in a new project's settings automatically (middleware as a general
concept gets its own full treatment in Middleware). This
middleware requires every incoming POST request to include a secret, per-session token that only
your own site's pages actually know — a token a malicious third-party page has no way to obtain or
guess.
The {% csrf_token %} Template Tag
To supply that token, every HTML form on your site that submits with POST must include the
{% csrf_token %} template tag inside the <form> element. It renders
as a hidden input carrying the token value, which Django then verifies against the session when the
form is submitted:
Example: A TaskFlow Form With CSRF Protection
{% extends "taskflow/base.html" %}
{% block content %}
<form method="post" action="{% url 'task-delete' task.id %}">
{% csrf_token %}
<button type="submit">Delete Task</button>
</form>
{% endblock %}
Leave {% csrf_token %} out of a POST form, and Django rejects the submission outright
with a 403 Forbidden error the moment it's submitted. That's not a bug to work
around — it's the protection doing precisely its job: refusing a POST request that can't prove it
came from a legitimate page on your own site. The fix is always to add the tag, never to disable
the check.
Why GET Requests Are Exempt
CSRF checks only apply to state-changing requests — POST, PUT, PATCH, DELETE — never to plain GET requests. This lines up with basic HTTP and REST conventions: a GET request is supposed to only retrieve data, never change it, so there's nothing destructive for an attacker to trigger by forging one. If a view performs a delete or update in response to a GET request, that's a design problem independent of CSRF — the fix is to make that action a POST, not to add CSRF exemptions around it.
Other Security Defaults Worth Knowing
CSRF protection isn't the only thing Django quietly handles for you. A few other defaults are worth knowing exist, even before you need to think hard about any of them:
| Default protection | What it guards against |
|---|---|
| ORM parameterized queries | SQL injection — the ORM never builds SQL by pasting your values directly into a query string, which is exactly why raw SQL string concatenation should be avoided |
| DTL auto-escaping | XSS (cross-site scripting) — {{ }} output is automatically HTML-escaped, covered in Django Template Language; the |safe filter turns this off explicitly, and should only ever be used on content you fully trust |
CsrfViewMiddleware | Cross-Site Request Forgery, covered above |
SECRET_KEY-based signing | Tampering with sessions and signed tokens, covered below |
The Role of SECRET_KEY
Every Django project has a SECRET_KEY setting, used internally for cryptographic
signing — it's what makes session cookies and password-reset tokens tamper-evident, since Django can
tell whether a value was signed with this exact key or forged. If SECRET_KEY ever leaks,
an attacker can forge those signed values themselves. It must never be committed to version control
or exposed in a public repository — the correct place for it is an environment variable or a local
secrets file kept out of source control entirely, covered properly in
Environment Configuration.
DEBUG = False in Production — the Security Angle
DEBUG = True, an unhandled error shows a detailed page with the full
stack trace, local variable values, and your settings — genuinely useful while developing, but a
serious information leak if a real visitor triggers an error on a live site. It can hand an attacker
internal file paths, configuration values, and clues about your database structure. Production
deployments must always run with DEBUG = False, no exceptions.