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 protectionWhat it guards against
ORM parameterized queriesSQL 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-escapingXSS (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
CsrfViewMiddlewareCross-Site Request Forgery, covered above
SECRET_KEY-based signingTampering 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

Note: With 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.
Next up: With authentication, permissions, and the core security defaults covered, The Django Admin: Basics introduces the tool you'll actually use day-to-day to manage TaskFlow's data.