Security

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is a security release scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was built and run on that stack in a scratch project (SQLite, the Django test client, the runserver development server and curl, and django-cors-headers 4.9.0); none needs PostgreSQL or Redis. Django ships with defences against the common web attacks, but most of them only work if the project keeps the middleware enabled, uses the template and ORM APIs as designed and sets a handful of deployment settings; this page explains each protection, where it sits in the request/response cycle, which setting controls it, how it fails, and how to test it in the Bookshelf application (the catalog app built across this section). It ends with the deployment checklist (check --deploy) and with Django’s own security policy, because keeping the framework patched is a security control too.

In a hurry: the checklist

Threat What to do Section

XSS

Render with templates (auto-escaping on), use format_html() instead of string building plus mark_safe(), quote attributes, pass data to JavaScript with json_script, add a Content Security Policy.

Cross-site scripting (XSS)

CSRF

Keep CsrfViewMiddleware, put {% csrf_token %} in every internal POST form, send X-CSRFToken from JavaScript, never csrf_exempt a browser-facing view.

Cross-site request forgery (CSRF)

SQL injection

Use the ORM; for raw SQL pass values in params, never in the SQL string; allow-list field names taken from the client.

SQL injection

Clickjacking

Keep XFrameOptionsMiddleware (DENY by default); exempt only the views that must be framed.

Clickjacking

Plain-text traffic

Serve only HTTPS: SECURE_SSL_REDIRECT, secure cookies, HSTS, and SECURE_PROXY_SSL_HEADER only when the proxy is trusted.

HTTPS, HSTS and security headers with SecurityMiddleware

Host header attacks

Set ALLOWED_HOSTS to the real host names; read the host with request.get_host().

Host header validation and ALLOWED_HOSTS

Hostile uploads

Limit sizes at the web server, validate extension and content, rename files, serve them as attachments or from another domain.

User-uploaded files

Mass assignment

Explicit fields lists in model forms and serializers; set author, is_staff and similar server-side.

Mass assignment

Open redirects

Validate next parameters with url_has_allowed_host_and_scheme().

Open redirects and other input-driven requests

Cross-origin API calls

Allow-list origins with django-cors-headers; CORS never replaces CSRF.

CORS with django-cors-headers

Misconfiguration

Run manage.py check --deploy against the production settings in CI.

check --deploy

Vulnerable Django

Track the supported series and apply security releases the day they ship.

Django’s security policy and release process

Where each protection runs in the request cycle

Almost every built-in protection is a middleware, so the order of MIDDLEWARE is part of the security configuration (see Middleware for the onion model and The Request/Response Cycle for the full path). The diagram follows a request into the Bookshelf and back out, with the stack shown in The middleware list, with the third-party CORS layer placed before CommonMiddleware (config/settings.py).

flowchart TD R["HTTP request"] --> S1["SecurityMiddleware: HTTPS redirect"] S1 --> SE["SessionMiddleware"] SE --> CO["CorsMiddleware (third party): preflight answer"] CO --> CM["CommonMiddleware: get_host() checks ALLOWED_HOSTS"] CM --> CS["CsrfViewMiddleware.process_view: token and Origin check"] CS --> AU["AuthenticationMiddleware"] AU --> V["View: form validation, ORM parameters, template escaping"] V --> X["XFrameOptionsMiddleware: X-Frame-Options"] X --> CS2["CsrfViewMiddleware: set csrftoken cookie"] CS2 --> CO2["CorsMiddleware: Access-Control-* headers"] CO2 --> S2["SecurityMiddleware: HSTS, nosniff, Referrer-Policy, COOP"] S2 --> RESP["HTTP response"]
Protection Position What happens on failure

HTTPS redirect (SECURE_SSL_REDIRECT)

SecurityMiddleware, on the way in

Answers 301 to the https:// URL without calling the rest of the stack.

Host validation (ALLOWED_HOSTS)

The first code that calls request.get_host(): typically CommonMiddleware, CsrfViewMiddleware or your own code

DisallowedHost is raised and Django answers 400, logging to django.security.DisallowedHost.

CSRF check

CsrfViewMiddleware.process_view, after the URL has been resolved and before the view

403 through CSRF_FAILURE_VIEW, logged to django.security.csrf; the view never runs.

Request size and field limits (DATA_UPLOAD_MAX_*)

When the view first reads request.body or request.POST

SuspiciousOperation subclass, turned into a 400.

Auto-escaping, parameterised queries, form validation

Inside the view, in the template engine, the ORM and the forms

Escaped output, bound parameters, is_valid() returning False.

X-Frame-Options

XFrameOptionsMiddleware, on the way out

Not applicable: it only adds the header when the response lacks one.

HSTS, nosniff, Referrer-Policy, Cross-Origin-Opener-Policy

SecurityMiddleware, on the way out

Not applicable: each header is added only if the response lacks it.

Two consequences follow from that layout. First, a response produced by a layer above another (a CORS preflight, an HTTPS redirect) never reaches the layers below it, so those layers' protections do not apply to it, and the layers above can only add headers to what comes back. Second, protections that live in the front-end web server (HTTPS redirect, size limits, Host validation, security headers) also cover the requests Django never sees, such as static files and media, which is why the documentation recommends doing them there when you can and treating Django’s versions as the fallback.

The Bookshelf security setup used on this page

The examples share one small project. The key is read from the environment and never committed; DJANGO_SECRET_KEY can be generated once with python -c "from django.core.management.utils import get_random_secret_key as g; print(g())".

The settings that every example relies on (config/settings.py, excerpt)
# The key never lives in source control: it is read from the environment.
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
The middleware list, with the third-party CORS layer placed before CommonMiddleware (config/settings.py)
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

The models that the examples use are a Review with a staff-only featured flag and a Cover image with upload validators.

The models (catalog/models.py)
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models

from .uploads import ALLOWED_COVER_EXTENSIONS, cover_upload_path, validate_cover_size


class Review(models.Model):
    book = models.CharField(max_length=200)
    rating = models.PositiveSmallIntegerField()
    body = models.TextField(blank=True)
    # Staff-only flag: a form must never let a reader set it.
    featured = models.BooleanField(default=False)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
    )

    def __str__(self):
        return f"{self.book} ({self.rating})"


class Cover(models.Model):
    book = models.CharField(max_length=200)
    image = models.ImageField(
        upload_to=cover_upload_path,
        validators=[
            FileExtensionValidator(ALLOWED_COVER_EXTENSIONS),
            validate_cover_size,
        ],
    )

Always sanitize user input

The golden rule of the security overview is that user-controlled data is never trusted. Django’s tool for that is the forms framework: declare the shape of valid data once, call is_valid(), and use only cleaned_data. Anything that arrives in request.GET, request.POST, request.body, request.FILES, cookies, headers, the URL and even data from web services or the database that a user once supplied is input. The sections below are the specific places where unvalidated input becomes an attack; for forms and validators see Forms and Form Validation and Validators.

The security team applies the same rule when it triages reports: a report that depends on unsanitised input reaching send_mail() or extra() is not a framework vulnerability, because validating input is the developer’s job.

Cross-site scripting (XSS)

In an XSS attack, script written by an attacker runs in another user’s browser with that user’s privileges: it can read the page, send requests as the user and read anything not marked HttpOnly. The script reaches the victim either stored (a review body saved in the database and shown to every reader) or reflected (a crafted link whose query string is echoed by the page). In Django the primary defence is automatic escaping in the template engine, and the common ways to lose it are mark_safe(), the safe filter, autoescape off, HTML stored in the database and unquoted attributes.

Auto-escaping

The Django template language escapes <, >, ', " and & in every variable it renders, so attacker-supplied markup is shown as text. The protection is for HTML output; if you render something else (JSON, JavaScript, CSV, a plain-text email) with the template engine, the characters that need escaping are different and auto-escaping is not enough. The template side of escaping is covered on Templates and Custom Template Tags and Filters (the is_safe and needs_autoescape flags of custom filters).

Reflected input, escaped by the template (catalog/views.py, catalog/templates/catalog/greeting.html)
def greeting(request):
    # `name` is attacker-controlled; the template auto-escapes it.
    return render(request, "catalog/greeting.html", {"name": request.GET.get("name", "reader")})


def badge(request):
    # format_html() escapes every argument and marks only the result safe.
    html = format_html('<span class="badge">{}</span>', request.GET.get("label", "new"))
    return HttpResponse(html)


def chart(request):
    data = {"title": request.GET.get("title", "Ratings"), "values": [3, 5, 4]}
    return render(request, "catalog/chart.html", {"data": data})
<!doctype html>
<title>Greeting</title>
<p class="greeting">Hello, {{ name }}!</p>
<a class="next" href="/search/?q={{ name|urlencode }}">search</a>
<div class="{{ name }}">quoted attribute</div>

The unquoted-attribute case from the security overview is the reason the last line quotes the attribute: with <div class={{ name }}> a value such as x onmouseover=alert(1) adds an event handler even though every character is "escaped", because the browser parses the missing quotes differently from the author’s intent. Always write class="{{ name }}".

Running the greeting view with a script payload on the development server
$ curl -s 'http://127.0.0.1:8743/greeting/?name=%3Cscript%3Ealert(1)%3C/script%3E'
<!doctype html>
<title>Greeting</title>
<p class="greeting">Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p>
<a class="next" href="/search/?q=%3Cscript%3Ealert%281%29%3C/script%3E">search</a>
<div class="&lt;script&gt;alert(1)&lt;/script&gt;">quoted attribute</div>
Tests for escaping (catalog/tests.py)
import hashlib
import hmac
import io
import secrets

from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import AsyncClient, Client, TestCase, override_settings
from PIL import Image

from .forms import ReviewForm
from .models import Cover, Review

User = get_user_model()


def make_user(**extra):
    # A throwaway password generated for each test run, never a literal.
    password = secrets.token_urlsafe(16)
    user = User.objects.create_user("reader", password=password, **extra)
    return user, password


def png_bytes():
    buffer = io.BytesIO()
    Image.new("RGB", (4, 4), "white").save(buffer, "PNG")
    return buffer.getvalue()


class XssTests(TestCase):
    def test_autoescape(self):
        payload = "<script>alert(1)</script>"
        response = self.client.get("/greeting/", {"name": payload})
        body = response.content.decode()
        self.assertNotIn("<script>", body)
        self.assertIn("&lt;script&gt;alert(1)&lt;/script&gt;", body)

    def test_unquoted_vs_quoted_attribute(self):
        response = self.client.get("/greeting/", {"name": "x onmouseover=alert(1)"})
        # the quoted attribute keeps the payload inside the value
        self.assertIn('<div class="x onmouseover=alert(1)">', response.content.decode())

    def test_format_html(self):
        response = self.client.get("/badge/", {"label": "<img src=x onerror=alert(1)>"})
        self.assertEqual(
            response.content.decode(),
            '<span class="badge">&lt;img src=x onerror=alert(1)&gt;</span>',
        )

    def test_json_script(self):
        response = self.client.get("/chart/", {"title": "</script><script>alert(1)"})
        body = response.content.decode()
        self.assertIn('<script id="chart-data" type="application/json">', body)
        self.assertNotIn("</script><script>alert(1)", body)
        self.assertIn("\\u003C/script\\u003E", body)

The escape hatches and how to use them safely

mark_safe(), the safe filter and {% autoescape off %} all tell Django "this string is already safe". They are promises, not filters: nothing is checked. A safe string built by interpolating user input is exactly the bug the template engine exists to prevent. format_html() is the correct way to build a small HTML fragment in Python: the format string is trusted, every argument is escaped with conditional_escape() first, and only the final result is marked safe. format_html_join() does the same for a list of fragments (since 5.2 it also accepts an iterable of mappings that become keyword arguments), conditional_escape() avoids double escaping, and escape() is the plain function for text going into HTML.

The mark_safe() trap (catalog/tests.py); format_html() is used by the badge view shown above
class MarkSafeTests(TestCase):
    def test_mark_safe_is_a_promise_not_a_filter(self):
        from django.template import Context, Template
        from django.utils.html import escape
        from django.utils.safestring import mark_safe

        payload = "<script>alert(1)</script>"
        wrong = mark_safe(f"<b>{payload}</b>")  # the f-string bypasses escaping
        right = mark_safe(f"<b>{escape(payload)}</b>")
        template = Template("{{ value }}")
        self.assertIn("<script>", template.render(Context({"value": wrong})))
        self.assertNotIn("<script>", template.render(Context({"value": right})))

strip_tags() removes anything that looks like a tag but the documentation gives no guarantee that its output is safe, so never mark its result safe without escaping it, and do not use it as a sanitizer. If users may submit rich text, run it through a dedicated third-party HTML sanitising library on input and on output, and keep the allowed tags and attributes to the minimum. Stored HTML from the database is untrusted unless it is guaranteed to come from a trusted source, which user input never is.

Passing data to JavaScript

Do not build script blocks by interpolating variables. json_script serialises a value as JSON, escapes <, > and & as Unicode escapes so it cannot close the <script> element, and wraps it in a <script type="application/json"> element that your own script reads with JSON.parse. The escapejs filter exists for the rarer case of a string inside an existing script literal.

json_script (catalog/templates/catalog/chart.html)
<!doctype html>
<title>Chart</title>
<canvas id="chart"></canvas>
{{ data|json_script:"chart-data" }}
<script>
  const data = JSON.parse(document.getElementById("chart-data").textContent);
  // use data.title and data.values
</script>

The test in Auto-escaping requests /chart/ with a title of </script><script>alert(1) and checks that the output contains </script> and never the raw closing tag.

Defence in depth: HttpOnly cookies and a Content Security Policy

Two settings reduce what an XSS bug can do. SESSION_COOKIE_HTTPONLY is True by default, which hides the session cookie from JavaScript. A Content Security Policy tells the browser which sources of script, style and frames are allowed, so injected inline script is blocked even when escaping failed; it is built into Django since 6.0 and described on Content Security Policy. Neither replaces escaping, and the documentation warns against excluding paths from the policy because a hole on one page can be used to attack the protected ones (same-origin policy).

Cross-site request forgery (CSRF)

A CSRF attack makes a logged-in user’s browser send a request the user did not intend, for example a hidden form on an attacker’s page that posts to /reviews/new/; the browser attaches the session cookie automatically, so the site cannot tell the request from a real one. Django’s defence has three parts: safe methods must be side-effect free, unsafe methods must carry a secret that the attacker’s page cannot read, and the Origin (or, on HTTPS, Referer) header must come from a trusted origin.

How the protection works

  • A random secret is stored in the csrftoken cookie (or in the session, with CSRF_USE_SESSIONS). The middleware sets it whenever get_token() is called, for example by the {% csrf_token %} tag, and the secret changes every time a user logs in.

  • The {% csrf_token %} tag renders a hidden csrfmiddlewaretoken field. To defeat BREACH attacks the field is the secret scrambled with a new random mask on every call, so its value differs on every response.

  • For every request whose method is not GET, HEAD, OPTIONS or TRACE, CsrfViewMiddleware.process_view requires the cookie and either the csrfmiddlewaretoken form field or the X-CSRFToken header (named by CSRF_HEADER_NAME, whose default is 'HTTP_X_CSRFTOKEN'); a missing or wrong token gives a 403.

  • If the browser sent an Origin header it must match the request’s own origin or CSRF_TRUSTED_ORIGINS. For a secure (HTTPS) request without Origin, the Referer must match the host or a trusted origin. Both checks close the cross-subdomain and man-in-the-middle holes of a cookie-only scheme.

The protection is not linked to a session by default, which is deliberate: it also works for anonymous submissions. Rejected requests are logged as warnings to the django.security.csrf logger.

Protecting forms

The middleware is on by default. The only work in templates is the tag inside every POST form that targets an internal URL; never put it in a form that posts to an external site, because that leaks the token. The view must render with a RequestContext (render(), generic views and the contrib apps already do). In Jinja2 templates the backend exposes {{ csrf_input }} instead of the tag.

A protected form (catalog/templates/catalog/review_form.html) and its view (excerpt of catalog/views.py)
<!doctype html>
<title>New review</title>
<form method="post">{% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Save</button>
</form>
@login_required
def review_create(request):
    form = ReviewForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        review = form.save(commit=False)
        review.author = request.user  # set server-side, never from the form
        review.save()
        return redirect("review-created")
    return render(request, "catalog/review_form.html", {"form": form})


def review_created(request):
    return HttpResponse("Thanks for the review.")


@ensure_csrf_cookie
def spa_shell(request):
    # A page with no <form>: force Django to set the csrftoken cookie.
    return render(request, "catalog/spa.html")


@login_required
@require_POST
def api_review(request):
    try:
        payload = json.loads(request.body)
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid JSON"}, status=400)
    form = ReviewForm(payload)
    if not form.is_valid():
        return JsonResponse(form.errors, status=400)
    review = form.save(commit=False)
    review.author = request.user
    review.save()
    return JsonResponse({"id": review.pk}, status=201)


@csrf_exempt
@require_POST
def payment_webhook(request):
    # Exempt from CSRF because the caller is a server, not a browser; the
    # request is authenticated by an HMAC signature instead.
    secret = settings.WEBHOOK_SECRET.encode()
    expected = hmac.new(secret, request.body, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Signature", "")
    if not hmac.compare_digest(expected, received):
        return HttpResponse(status=403)
    return HttpResponse(status=204)


@csrf_protect
async def async_form(request):
    # Decorators wrap async views since Django 5.0.
    return HttpResponse("ok")

AJAX and single-page front ends

A fetch or XHR call has no hidden field, so it sends the token in a header. Read it from the csrftoken cookie and send it as X-CSRFToken. If CSRF_USE_SESSIONS or CSRF_COOKIE_HTTPONLY is on, JavaScript cannot read the cookie: render the token into the page with the tag and read the hidden input instead. A page that has no form never triggers the cookie, so decorate the view that serves it with ensure_csrf_cookie (the spa_shell view above). Use mode: "same-origin" or credentials: "same-origin" so the token is not sent to another domain.

Reading the cookie and sending the header (catalog/templates/catalog/spa.html)
<!doctype html>
<title>Shell</title>
<div id="app"></div>
<script>
  // Read the csrftoken cookie, then send it back in the X-CSRFToken header.
  function getCookie(name) {
    const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
    return match ? decodeURIComponent(match[1]) : null;
  }
  async function saveReview(review) {
    const response = await fetch("/api/reviews/", {
      method: "POST",
      headers: {"Content-Type": "application/json", "X-CSRFToken": getCookie("csrftoken")},
      body: JSON.stringify(review),
      credentials: "same-origin",
    });
    return response.json();
  }
</script>

Testing the protection

The test client skips CSRF checks so that ordinary view tests are not cluttered. A security test must switch the checks back on with Client(enforce_csrf_checks=True) (and AsyncClient(enforce_csrf_checks=True) for async code).

CSRF tests (catalog/tests.py)
class CsrfTests(TestCase):
    def setUp(self):
        self.user, self.password = make_user()
        self.client = Client(enforce_csrf_checks=True)

    def login(self):
        # force_login skips the CSRF-protected login form.
        self.client.force_login(self.user)

    def test_post_without_token_is_rejected(self):
        self.login()
        response = self.client.post("/reviews/new/", {"book": "Dune", "rating": 5})
        self.assertEqual(response.status_code, 403)
        self.assertEqual(Review.objects.count(), 0)

    def test_post_with_token_succeeds(self):
        self.login()
        page = self.client.get("/reviews/new/")
        self.assertContains(page, "csrfmiddlewaretoken")
        token = page.context["csrf_token"]
        response = self.client.post(
            "/reviews/new/",
            {"book": "Dune", "rating": 5, "csrfmiddlewaretoken": str(token)},
        )
        self.assertEqual(response.status_code, 302)
        self.assertEqual(Review.objects.get().author, self.user)

    def test_ajax_header(self):
        self.login()
        shell = self.client.get("/spa/")
        cookie = shell.cookies["csrftoken"].value
        body = '{"book": "Dune", "rating": 5}'
        denied = self.client.post("/api/reviews/", body, content_type="application/json")
        self.assertEqual(denied.status_code, 403)
        allowed = self.client.post(
            "/api/reviews/",
            body,
            content_type="application/json",
            headers={"X-CSRFToken": cookie},
        )
        self.assertEqual(allowed.status_code, 201)

    def test_cross_origin_post_is_rejected(self):
        self.login()
        shell = self.client.get("/spa/")
        cookie = shell.cookies["csrftoken"].value
        with self.assertLogs("django.security.csrf", level="WARNING") as logs:
            response = self.client.post(
                "/api/reviews/",
                '{"book": "Dune", "rating": 5}',
                content_type="application/json",
                headers={"X-CSRFToken": cookie, "Origin": "https://evil.example.net"},
            )
        self.assertEqual(response.status_code, 403)
        self.assertIn("Origin checking failed", logs.output[0])

    def test_trusted_origin_is_accepted(self):
        self.login()
        shell = self.client.get("/spa/")
        cookie = shell.cookies["csrftoken"].value
        response = self.client.post(
            "/api/reviews/",
            '{"book": "Dune", "rating": 5}',
            content_type="application/json",
            headers={"X-CSRFToken": cookie, "Origin": "https://app.example.com"},
            secure=True,
        )
        self.assertEqual(response.status_code, 201)

    def test_webhook_is_exempt_but_signed(self):
        secret = secrets.token_hex(16)
        body = b'{"event": "paid"}'
        signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
        with override_settings(WEBHOOK_SECRET=secret):
            ok = self.client.post("/hooks/payment/", body, content_type="application/json",
                                  headers={"X-Signature": signature})
            bad = self.client.post("/hooks/payment/", body, content_type="application/json",
                                   headers={"X-Signature": "0" * 64})
        self.assertEqual((ok.status_code, bad.status_code), (204, 403))


class AsyncCsrfTests(TestCase):
    async def test_async_view_is_protected(self):
        client = AsyncClient(enforce_csrf_checks=True)
        response = await client.post("/async-form/")
        self.assertEqual(response.status_code, 403)

Running them logs the rejections, which shows what an operator would see:

Forbidden (CSRF cookie not set.): /reviews/new/
Forbidden (CSRF token missing.): /api/reviews/
Forbidden (Origin checking failed - https://evil.example.net does not match any trusted origins.): /api/reviews/

CSRF settings

Setting Default Effect

CSRF_COOKIE_NAME

'csrftoken'

Name of the cookie; must differ from your other cookies.

CSRF_COOKIE_AGE

31449600 (about a year)

Lifetime in seconds; long so that a bookmarked or cached form still works.

CSRF_COOKIE_DOMAIN

None

Set to ".example.com" to accept posts between subdomains; this does not make cross-subdomain use safe (see the limitations below).

CSRF_COOKIE_PATH

'/'

Cookie path, useful when several Django instances share a host name.

CSRF_COOKIE_SECURE

False

Mark the cookie Secure so it is only sent over HTTPS; set it in production.

CSRF_COOKIE_HTTPONLY

False

Hides the cookie from JavaScript; the documentation says it gives little practical protection against CSRF and exists mostly for auditors; if you enable it, read the token from a hidden input.

CSRF_COOKIE_SAMESITE

'Lax'

SameSite flag; may need loosening if a form inside a cross-site frame must post.

CSRF_USE_SESSIONS

False

Store the secret in the session instead of a cookie (requires django.contrib.sessions).

CSRF_HEADER_NAME

'HTTP_X_CSRFTOKEN'

request.META name of the header; a client sending X-XSRF-TOKEN needs 'HTTP_X_XSRF_TOKEN'.

CSRF_TRUSTED_ORIGINS

[]

Origins (with scheme, since 4.0) whose unsafe requests are accepted; supports 'https://*.example.com'.

CSRF_FAILURE_VIEW

'django.views.csrf.csrf_failure'

View used for a rejected request, signature csrf_failure(request, reason=""); the default renders 403_csrf.html if it exists.

Decorators for the edge cases

Decorator Use

csrf_exempt

Opts a view out of the middleware’s check. Reserve it for endpoints called by servers, not browsers, and authenticate them another way (the webhook view above verifies an HMAC signature with hmac.compare_digest).

csrf_protect

Applies the middleware’s check to one view. Use it as belt and braces when the middleware might be removed, and in reusable apps (the contrib apps do), and below cache_page so the cached response keeps the cookie and Vary: Cookie.

requires_csrf_token

Makes the tag work in a view where process_view did not run (404 and 500 handlers) without ever rejecting a request.

ensure_csrf_cookie

Forces the cookie to be sent from a view that renders no form.

Combining them follows one rule: csrf_exempt followed by requires_csrf_token (innermost) gives an unprotected view that can still render a token. Since Django 5.0 all four decorators also wrap async def views, which AsyncCsrfTests above checks: an async view protected by csrf_protect rejects a token-less post with 403.

Limitations and pitfalls

  • Any subdomain can set cookies for the whole domain, so a subdomain controlled by an untrusted party can defeat CSRF protection (and enables session fixation); do not give untrusted users subdomains.

  • CSRF protection assumes there is no XSS on the site, valid Host headers and HTTPS with HSTS, because it cannot defend against man-in-the-middle attacks.

  • Referrer-Policy: no-referrer (or a no-referrer meta tag) removes the header that strict Referer checking needs on HTTPS, so every unsafe request starts failing; use rel="noreferrer" on third-party links instead.

  • The first request after a login can fail with a CSRF error from a page loaded before the login, because the secret is rotated at login (back button, a second tab); reload the page.

  • Posting an arbitrary cookie and token pair is not a vulnerability by design, since only a man-in-the-middle could plant the cookie.

  • Django REST framework’s SessionAuthentication enforces CSRF itself, and Django Ninja’s cookie-based authentication classes check it by default, while header-token authentication does not need it; see the API pages of this section.

SQL injection

SQL injection happens when user input becomes part of the SQL text instead of a value, letting the attacker change the query. Querysets are protected because the query is built with parameterisation: the SQL text and the parameters travel separately and the driver escapes the parameters. The risk returns with the raw-SQL features and with a few places where the client chooses a field name rather than a value.

API Rule

filter(), exclude(), get(), Q, F

Safe for values. Never expand a client-supplied dictionary into keyword arguments (filter(**request.GET.dict())): the key is a lookup path, so a client could filter on authorpasswordstartswith and learn hashes one character at a time.

order_by(), values(), only()

Safe for values but the field name comes from the client: map it through an allow-list, otherwise clients can sort by (and so infer) related or sensitive fields.

Manager.raw()

Put values in the params argument and use %s placeholders without quotes; dictionary params use %(name)s and are not supported by SQLite.

connection.cursor().execute()

Same: execute(sql, [params]); never format the SQL with % or f-strings.

QuerySet.extra() and RawSQL

Full control over the SQL, so insecure with unsanitised input; the documentation says to use them sparingly, and the security team treats unsanitised use as the developer’s bug. Prefer expressions and annotations.

Keyword arguments of database functions and aggregates

Some are passed to the SQL; treat them as code, not data.

Safe queries and the client-chosen names allow-listed (excerpt of catalog/views.py)
SORTABLE = {"title": "book", "rating": "rating"}
FILTERABLE = {"book__icontains", "rating__gte"}


def search(request):
    term = request.GET.get("q", "")
    # ORM: the value travels as a bound parameter, never inside the SQL text.
    reviews = Review.objects.filter(book__icontains=term)

    # Never pass a client-chosen field name straight to order_by() or filter():
    # map it through an allow-list.
    sort = SORTABLE.get(request.GET.get("sort", ""), "book")
    reviews = reviews.order_by(sort)

    extra = {k: v for k, v in request.GET.items() if k in FILTERABLE}
    reviews = reviews.filter(**extra)
    return JsonResponse({"books": [r.book for r in reviews]})


def search_raw(request):
    term = request.GET.get("q", "")
    # Raw SQL is fine when the value goes through `params`.
    reviews = Review.objects.raw(
        "SELECT * FROM catalog_review WHERE book = %s", [term]
    )
    return JsonResponse({"books": [r.book for r in reviews]})
SQL injection tests, including the injectable formatted query (catalog/tests.py)
class SqlTests(TestCase):
    def setUp(self):
        user, _ = make_user()
        for title in ("Dune", "Emma"):
            Review.objects.create(book=title, rating=4, author=user)

    def test_orm_treats_input_as_data(self):
        r = self.client.get("/search/", {"q": "' OR '1'='1"})
        self.assertEqual(r.json(), {"books": []})

    def test_raw_params(self):
        r = self.client.get("/search-raw/", {"q": "' OR '1'='1"})
        self.assertEqual(r.json(), {"books": []})
        r = self.client.get("/search-raw/", {"q": "Dune"})
        self.assertEqual(r.json(), {"books": ["Dune"]})

    def test_string_formatting_is_injectable(self):
        term = "' OR '1'='1"
        reviews = Review.objects.raw(f"SELECT * FROM catalog_review WHERE book = '{term}'")
        self.assertEqual(len(list(reviews)), 2)  # the attacker read every row

    def test_allow_lists(self):
        r = self.client.get("/search/", {"sort": "author__password", "rating__gte": "1",
                                          "author__password__startswith": "x"})
        self.assertEqual(r.status_code, 200)
        self.assertEqual(r.json(), {"books": ["Dune", "Emma"]})

The test test_string_formatting_is_injectable shows why formatting is wrong: the same payload that returns nothing through the ORM or params returns every row when the text is spliced into the SQL. The last test sends a hostile sort and a hostile lookup key and gets the normal listing because only allow-listed names are used. For raw(), cursors and transactions see Transactions, Raw SQL and Multiple Databases and for lookups Making Queries.

Clickjacking

In a clickjacking attack a malicious page loads your site in a transparent iframe and lines up a button of yours under a button of its own, so the user’s click performs an action on your site. Browsers honour the X-Frame-Options response header: DENY forbids framing, SAMEORIGIN allows only your own pages to frame it. XFrameOptionsMiddleware adds it to every response that does not already carry one, and it is in the startproject template. The default value comes from X_FRAME_OPTIONS, which defaults to 'DENY'.

Three decorators change the header per view and override the middleware: xframe_options_deny, xframe_options_sameorigin and xframe_options_exempt (which tells the middleware not to add the header, for widgets that other sites must frame). If a form inside a frame must post, or a session cookie must be sent from inside it, you may also need to loosen CSRF_COOKIE_SAMESITE or SESSION_COOKIE_SAMESITE. The decorators wrap async views since 5.0. The header only protects in modern browsers; the Content Security Policy frame-ancestors directive is its modern complement.

Per-view framing policy (excerpt of catalog/views.py)
@xframe_options_exempt
def embeddable_widget(request):
    return HttpResponse("a widget other sites may frame")


@xframe_options_sameorigin
def own_frame_only(request):
    return HttpResponse("framed only by this site")


@xframe_options_deny
def never_framed(request):
    return HttpResponse("never framed")
Clickjacking tests (catalog/tests.py)
class ClickjackingTests(TestCase):
    def test_headers(self):
        self.assertEqual(self.client.get("/greeting/")["X-Frame-Options"], "DENY")
        self.assertNotIn("X-Frame-Options", self.client.get("/widget/"))
        self.assertEqual(self.client.get("/own-frame/")["X-Frame-Options"], "SAMEORIGIN")
        self.assertEqual(self.client.get("/never-framed/")["X-Frame-Options"], "DENY")

HTTPS, HSTS and security headers with SecurityMiddleware

Without HTTPS an attacker on the network can read credentials and session cookies and alter responses. Any site that lets users log in should enforce HTTPS site-wide (the same session cookie travels over HTTP and HTTPS, so protecting only the login or admin pages is not enough). django.middleware.security.SecurityMiddleware does the Django-side part and should be the first entry of MIDDLEWARE; it is both a request and a response layer (see Where each protection runs in the request cycle). Each feature is enabled by its own setting.

Setting Default Effect

SECURE_SSL_REDIRECT

False

Redirects every non-HTTPS request with a 301. Slightly cheaper and safer to do in the front proxy.

SECURE_SSL_HOST

None

Sends all redirects to this host instead of the requested one.

SECURE_REDIRECT_EXEMPT

[]

Regular expressions of paths that stay on HTTP; leading slashes are stripped before matching, so write ^healthz/$.

SECURE_HSTS_SECONDS

0

Non-zero sends Strict-Transport-Security on HTTPS responses; the browser then refuses plain HTTP to the domain for that long.

SECURE_HSTS_INCLUDE_SUBDOMAINS

False

Adds includeSubDomains; recommended only if every subdomain is HTTPS-only.

SECURE_HSTS_PRELOAD

False

Adds preload, a prerequisite for submitting the domain to browser preload lists.

SECURE_CONTENT_TYPE_NOSNIFF

True

Sends X-Content-Type-Options: nosniff so browsers do not guess the content type of uploads.

SECURE_REFERRER_POLICY

'same-origin'

Sends Referrer-Policy; accepts one of the standard values, an iterable or a comma-separated fallback list (the last value the browser understands wins).

SECURE_CROSS_ORIGIN_OPENER_POLICY

'same-origin'

Sends Cross-Origin-Opener-Policy (same-origin-allow-popups, unsafe-none or None to omit) so cross-origin popups cannot reach window.opener.

Related settings that make the picture complete: SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE (both default False; set them so the cookies are never sent over HTTP; sessions and POSTs then fail over plain HTTP, which is fine behind a redirect), SESSION_COOKIE_HTTPONLY (default True) and SESSION_COOKIE_SAMESITE (default 'Lax'; 'Strict' suits pages that must never be reached from a link on another site, 'None' sends the cookie cross-site). Session security is covered on Sessions, Cookies, Messages and Signing.

Redirect, HSTS and header tests (catalog/tests.py)
class SecurityMiddlewareTests(TestCase):
    @override_settings(
        SECURE_SSL_REDIRECT=True,
        SECURE_HSTS_SECONDS=3600,
        SECURE_HSTS_INCLUDE_SUBDOMAINS=True,
        SECURE_HSTS_PRELOAD=True,
        SECURE_REFERRER_POLICY="same-origin",
    )
    def test_redirect_and_headers(self):
        redirect = self.client.get("/greeting/")
        self.assertEqual(redirect.status_code, 301)
        self.assertEqual(redirect["Location"], "https://testserver/greeting/")
        secure = self.client.get("/greeting/", secure=True)
        self.assertEqual(secure.status_code, 200)
        self.assertEqual(
            secure["Strict-Transport-Security"],
            "max-age=3600; includeSubDomains; preload",
        )
        self.assertEqual(secure["X-Content-Type-Options"], "nosniff")
        self.assertEqual(secure["Referrer-Policy"], "same-origin")
        self.assertEqual(secure["Cross-Origin-Opener-Policy"], "same-origin")

    @override_settings(SECURE_SSL_REDIRECT=True)
    def test_proxy_header(self):
        looped = self.client.get("/greeting/", headers={"X-Forwarded-Proto": "https"})
        self.assertEqual(looped.status_code, 301)  # Django does not trust the header yet
        with override_settings(SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https")):
            ok = self.client.get("/greeting/", headers={"X-Forwarded-Proto": "https"})
        self.assertEqual(ok.status_code, 200)

    @override_settings(SECURE_SSL_REDIRECT=True, SECURE_REDIRECT_EXEMPT=[r"^health/$"],
                       SECURE_SSL_HOST="secure.example.com")
    def test_exempt_and_ssl_host(self):
        r = self.client.get("/greeting/")
        self.assertEqual(r["Location"], "https://secure.example.com/greeting/")

Rolling HSTS out safely

HSTS applies to the whole domain, not just the URL that carried the header, and browsers will not let users click through an expired or self-signed certificate once it is cached. Start with SECURE_HSTS_SECONDS = 3600, confirm that every asset and every subdomain you included works over HTTPS, then raise the value (31536000, one year, is common); add includeSubDomains and preload last, because preloading is hard to undo.

Behind a reverse proxy

When TLS ends at a proxy, Django sees plain HTTP: request.is_secure() is False, SECURE_SSL_REDIRECT redirects forever and HSTS is never sent. SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") tells Django which header to believe (written as in request.META; since 4.1 a comma-separated list is accepted and the leftmost value is used). It is only safe if all of these are true: the app is behind a proxy, the proxy removes that header from every incoming request (including comma-separated forms) and sets it only for requests that arrived over HTTPS. If any is not true, leave the setting None; an attacker who can send the header can make Django treat plain HTTP as secure, which weakens CSRF checks. The test test_proxy_header shows both states: with the header and no setting the redirect still happens, with the setting it does not.

USE_X_FORWARDED_HOST (default False) makes Django prefer X-Forwarded-Host over Host, and USE_X_FORWARDED_PORT does the same for the port; enable them only behind a proxy that sets them, and the host is then still validated against ALLOWED_HOSTS.

Host header validation and ALLOWED_HOSTS

Django builds absolute URLs (password-reset links, redirects, request.build_absolute_uri()) from the Host header. The header is client-controlled, so a forged value can poison links in emails, caches and CSRF checks. Even a web server that looks like it validates the header often does not (a request can match a default virtual host and still carry a fake Host), so Django validates it itself in HttpRequest.get_host() against ALLOWED_HOSTS, and a mismatch is a SuspiciousOperation that Django turns into a 400 and logs to django.security.DisallowedHost.

Entry Matches

'www.bookshelf.example'

That exact host, case-insensitively, ignoring the port.

'.bookshelf.example'

The apex bookshelf.example and every subdomain.

'*'

Anything; you are then responsible for validating the header yourself, in a middleware placed first.

[] with DEBUG = True

Validated against ['.localhost', '127.0.0.1', '[::1]'].

[] with DEBUG = False

Every request fails with 400; Django does not work without a value.

A trailing dot in the header (some browsers send it) is stripped before matching. The setting is also checked when running tests, which is why the test client uses testserver (added automatically by the test runner, and the reason the @override_settings(ALLOWED_HOSTS=[…​]) test below sends its own Host). Code that reads request.META["HTTP_HOST"] directly bypasses the validation: always call request.get_host().

Host validation test (catalog/tests.py)
class HostHeaderTests(TestCase):
    @override_settings(ALLOWED_HOSTS=["www.bookshelf.example", ".api.bookshelf.example"])
    def test_hosts(self):
        with self.assertLogs("django.security.DisallowedHost", level="ERROR") as logs:
            bad = self.client.get("/greeting/", headers={"Host": "evil.example.net"})
        self.assertEqual(bad.status_code, 400)
        self.assertIn("Invalid HTTP_HOST header", logs.output[0])
        good = self.client.get("/greeting/", headers={"Host": "www.bookshelf.example"})
        self.assertEqual(good.status_code, 200)
        sub = self.client.get("/greeting/", headers={"Host": "eu.api.bookshelf.example:8000"})
        self.assertEqual(sub.status_code, 200)
        apex = self.client.get("/greeting/", headers={"Host": "api.bookshelf.example"})
        self.assertEqual(apex.status_code, 200)

Against the development server with ALLOWED_HOSTS set to localhost,127.0.0.1 the same attack is rejected:

$ curl -si -H "Host: evil.example.net" http://127.0.0.1:8743/greeting/ | head -1
HTTP/1.1 400 Bad Request

Also configure the web server in front of Django to answer unknown hosts itself (for example nginx’s default server returning 444), so bad requests do not fill the Django logs, and never rely on the server alone.

User-uploaded files

Uploads are the largest attack surface of many sites: denial of service by size, code execution if the web server interprets the file, stored XSS if the browser renders it as HTML, path traversal through the file name. Django’s upload machinery (see File Uploads and Storage) helps, but the security overview is candid that no framework-level check can prove arbitrary content is safe.

Limit size and count

  • Limit the request body size in the web server (Apache LimitRequestBody, nginx client_max_body_size), because Django’s own limits are checked late: under ASGI the whole request may be spooled to disk before any size validation runs.

  • DATA_UPLOAD_MAX_MEMORY_SIZE (default 2.5 MB) caps the request body excluding file uploads, raising RequestDataTooBig when request.body or request.POST is read; form submissions containing files are not limited by it. DATA_UPLOAD_MAX_NUMBER_FIELDS (default 1000) caps parameters, and DATA_UPLOAD_MAX_NUMBER_FILES (default 100) caps files in a multipart request. FILE_UPLOAD_MAX_MEMORY_SIZE (default 2.5 MB) is the size above which an upload streams to a temporary file; it is not a limit.

  • Validate the size of each file in a validator too (see validate_cover_size below).

Tightened limits for the Bookshelf (config/settings.py, excerpt)
DATA_UPLOAD_MAX_MEMORY_SIZE = 1_000_000  # bytes of non-file request body
DATA_UPLOAD_MAX_NUMBER_FIELDS = 500
DATA_UPLOAD_MAX_NUMBER_FILES = 10
FILE_UPLOAD_MAX_MEMORY_SIZE = 1_000_000  # larger uploads are streamed to disk

Validate extension and content, and never trust the name

FileExtensionValidator checks the extension, ImageField makes Pillow verify the bytes are an image, and the model or form should also check the size. A random stored name, built from uuid4, removes both path-traversal and name-collision attacks and keeps client-chosen characters out of URLs and headers; Django’s storage also refuses paths that escape the storage root with SuspiciousFileOperation.

Validators and storage path (catalog/uploads.py)
import uuid
from pathlib import PurePath

from django.core.exceptions import ValidationError

MAX_COVER_BYTES = 2 * 1024 * 1024
ALLOWED_COVER_EXTENSIONS = ["png", "jpg", "jpeg", "webp"]


def validate_cover_size(file):
    if file.size > MAX_COVER_BYTES:
        raise ValidationError("Cover images are limited to 2 MB.")


def cover_upload_path(instance, filename):
    # Never reuse the client's file name: keep only the (validated) extension.
    extension = PurePath(filename).suffix.lower()
    return f"covers/{uuid.uuid4().hex}{extension}"

An important limit: a file that starts with a valid PNG header and continues with HTML still passes Pillow’s check, so ImageField is a convenience, not a security boundary. test_extension_and_content_checks below proves it: the polyglot form is valid.

Upload tests (catalog/tests.py)
class UploadTests(TestCase):
    def test_extension_and_content_checks(self):
        from .forms import ReviewForm  # noqa: F401
        from django import forms

        class CoverForm(forms.ModelForm):
            class Meta:
                model = Cover
                fields = ["book", "image"]

        def submit(name, data):
            return CoverForm({"book": "Dune"}, {"image": SimpleUploadedFile(name, data)})

        self.assertTrue(submit("cover.png", png_bytes()).is_valid())
        self.assertFalse(submit("cover.html", png_bytes()).is_valid())      # extension
        self.assertFalse(submit("cover.png", b"<script>alert(1)</script>").is_valid())  # not an image
        # A valid PNG followed by markup still passes Pillow's check:
        polyglot = png_bytes() + b"<script>alert(1)</script>"
        self.assertTrue(submit("cover.png", polyglot).is_valid())

    def test_random_stored_name(self):
        data = png_bytes()
        cover = Cover(book="Dune")
        cover.image.save("../../etc/evil.PNG", ContentFile(data))
        self.addCleanup(cover.image.delete, False)
        self.assertRegex(cover.image.name, r"^covers/[0-9a-f]{32}\.png$")

    def test_storage_blocks_traversal(self):
        with self.assertRaises(SuspiciousFileOperation):
            default_storage.save("/../../outside.txt", ContentFile(b"x"))

    def test_download_is_attachment(self):
        cover = Cover(book="Dune")
        cover.image.save("a.png", ContentFile(png_bytes()))
        self.addCleanup(cover.image.delete, False)
        r = self.client.get(f"/covers/{cover.pk}/download/")
        self.assertIn("attachment", r["Content-Disposition"])
        self.assertEqual(r["X-Content-Type-Options"], "nosniff")
        r.close()

    def test_limits(self):
        # reading request.body past DATA_UPLOAD_MAX_MEMORY_SIZE raises
        # RequestDataTooBig, a SuspiciousOperation, which Django turns into a 400
        with self.assertLogs("django.security.RequestDataTooBig", level="WARNING"):
            r = self.client.post("/hooks/payment/", b"a" * 1_100_000, content_type="text/plain")
        self.assertEqual(r.status_code, 400)

test_limits posts 1.1 MB to the webhook view, which reads request.body, and gets a 400 plus a django.security.RequestDataTooBig log record; test_storage_blocks_traversal shows SuspiciousFileOperation for a path that escapes the storage root.

Serve uploads defensively

  • Never serve media from the Django project’s own origin if you can avoid it. Serving it from a distinct top-level or second-level domain (usercontent-example.com, not usercontent.example.com) keeps the browser’s same-origin policy between uploaded content and your application, so a rendered polyglot cannot read your cookies or call your API.

  • Make sure the web server never interprets uploads (the Apache mod_php case): a .php file in MEDIA_ROOT must be downloaded or refused, never executed.

  • Keep SECURE_CONTENT_TYPE_NOSNIFF on (it is the default) so the browser trusts the Content-Type; configure the same header in the front server if it serves MEDIA_URL directly.

  • Restrict the allowed extensions and configure the web server to serve only those.

  • For files that need authorisation, stream them through a view with FileResponse(…​, as_attachment=True), which sends Content-Disposition: attachment so the browser saves the file instead of rendering it.

  • FILE_UPLOAD_PERMISSIONS defaults to 0o644 and FILE_UPLOAD_DIRECTORY_PERMISSIONS to None (operating-system dependent); set them deliberately on shared hosts.

A defensive download view (excerpt of catalog/views.py)
def cover_download(request, pk):
    cover = get_object_or_404(Cover, pk=pk)
    # as_attachment=True sends Content-Disposition: attachment, so the browser
    # saves the file instead of rendering it, whatever its bytes contain.
    return FileResponse(cover.image.open("rb"), as_attachment=True, filename=f"cover-{pk}.png")

Mass assignment

Mass assignment happens when a form or serializer binds every field a client sends to a model, so a reader can set featured=on, is_staff=on or author=1 simply by adding a parameter. In Django the cause is almost always a ModelForm whose Meta uses fields = "all" or exclude, and a Django REST framework serializer with fields = "all". The fix is an explicit allow-list of fields, and server-side assignment of everything the user must not choose.

The safe form (catalog/forms.py)
from django import forms

from .models import Review


class ReviewForm(forms.ModelForm):
    class Meta:
        model = Review
        # An explicit allow-list: `featured` and `author` can never be set
        # by a request, whatever extra POST fields an attacker adds.
        fields = ["book", "rating", "body"]

    def clean_rating(self):
        rating = self.cleaned_data["rating"]
        if not 1 <= rating <= 5:
            raise forms.ValidationError("Rating must be between 1 and 5.")
        return rating

The review_create view above saves with commit=False and sets review.author = request.user itself.

Mass assignment tests (catalog/tests.py)
class MassAssignmentTests(TestCase):
    def test_extra_fields_are_ignored(self):
        form = ReviewForm({"book": "Dune", "rating": 5, "featured": "on", "author": 1})
        self.assertTrue(form.is_valid())
        self.assertEqual(set(form.cleaned_data), {"book", "rating", "body"})
        self.assertFalse(form.instance.featured)

    def test_all_fields_would_expose_them(self):
        from django import forms

        class BadForm(forms.ModelForm):
            class Meta:
                model = Review
                fields = "__all__"

        self.assertIn("featured", BadForm().fields)
        self.assertIn("author", BadForm().fields)

exclude is a deny-list, so a field added to the model next year is silently exposed; fields is an allow-list, so it is not. Django (since 1.8) refuses a ModelForm that sets neither, which is why fields = "all" has to be spelled out and is easy to find in a review. For APIs the same rule applies: list the serializer fields, mark server-owned fields read-only, and use different serializers for create and for staff; a viewset that returns every customer’s orders because nothing scopes its queryset is the same class of bug at the row level (see Permissions and Authorization and Model Forms and Formsets). Do not expose sequential primary keys for resources that need to stay private; authorise every object access, or use a non-guessable identifier in addition.

Open redirects and other input-driven requests

A login or logout view that redirects to a next parameter without checking it lets an attacker send users to a look-alike site from a link on your domain. django.utils.http.url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False) returns True only for relative URLs and absolute ones whose host is in allowed_hosts; it also rejects javascript: and protocol-relative //host URLs. Django’s own LoginView and LogoutView use it; use it in every view that redirects to client-supplied data. Pass require_https=request.is_secure() so a secure page cannot be redirected to plain HTTP. The function is named in the 3.0 release notes (where it replaced the private is_safe_url()) but is not listed in the utilities reference, so the signature and behaviour described here were checked against the 6.1.1 source and the test below rather than a reference page; treat it as a stable helper that the framework itself relies on.

A safe redirect (excerpt of catalog/views.py) and its test
def login_redirect(request):
    target = request.GET.get("next", "")
    if not url_has_allowed_host_and_scheme(
        url=target,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    ):
        target = "/"
    return redirect(target)
class RedirectTests(TestCase):
    def test_open_redirect_blocked(self):
        for target, expected in [
            ("/reviews/created/", "/reviews/created/"),
            ("https://evil.example.net/", "/"),
            ("//evil.example.net/", "/"),
            ("javascript:alert(1)", "/"),
        ]:
            r = self.client.get("/go/", {"next": target})
            self.assertEqual(r["Location"], expected, target)

The same discipline covers server-side requests made with a client-supplied URL. A feature such as "import a cover from this URL" is server-side request forgery: restrict the scheme and host to an allow-list, resolve and check the address against private ranges, and set timeouts and a size cap. Django does not offer a helper for this.

Secrets, DEBUG and error pages

  • SECRET_KEY signs sessions (unless a cache-backed store is used), password-reset tokens, messages and signed cookies; a leaked key can lead to privilege escalation and remote code execution. It must be long, random, unique per environment and out of source control: read it from an environment variable or a file outside the repository, generate it with django.core.management.utils.get_random_secret_key(), and never reuse the django-insecure- value startproject writes.

  • SECRET_KEY_FALLBACKS (since 4.1) lets you rotate: set the new key as SECRET_KEY, keep the old one in the fallback list while old sessions and tokens age out, then remove it, because every verification that does not match the current key costs an extra signature check. check --deploy validates the fallbacks too (security.W025).

  • DEBUG must be False in production: the debug page leaks source excerpts, local variables, settings and installed libraries. Mark sensitive values with sensitive_variables and sensitive_post_parameters so they are hidden from error reports.

  • The database password and cache credentials are as sensitive as the key; keep the database and cache servers reachable only from the application servers (firewall), because cache servers often have weak authentication.

  • Custom 400.html, 403.html, 404.html and 500.html templates replace the default error pages.

  • Django does not throttle login attempts. Put a rate limit in the web server, use a third-party throttling or lockout app, and see Authentication and Passwords for the password side.

  • Keep the Python code outside the web server’s document root, and read the OWASP Top Ten for the issues the framework cannot solve for you (broken access control above all).

CORS with django-cors-headers

The browser’s same-origin policy stops a page on one origin from reading responses from another. Cross-Origin Resource Sharing (CORS) is the opt-in: the API answers with Access-Control-Allow-Origin and related headers. Django has no built-in CORS support; the community package django-cors-headers (4.9.0, PyPI classifiers up to Django 6.0, installed and run here with 6.1.1) is the usual choice. The protocol, the preflight and the common mistakes are explained on CORS; this section covers only the Django configuration.

$ python -m pip install django-cors-headers

Add "corsheaders" to INSTALLED_APPS and corsheaders.middleware.CorsMiddleware to MIDDLEWARE as high as possible, and in particular before any middleware that can generate responses, such as CommonMiddleware (and WhiteNoise’s middleware, if you use it), otherwise redirects and error responses lack the headers. The middleware answers preflight (OPTIONS with Access-Control-Request-Method) requests itself, so they never reach the view or CSRF middleware, and adds Vary: Origin to enabled responses.

The settings, as used in the Bookshelf (config/settings.py, excerpt; the middleware position is in the stack above)
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CORS_URLS_REGEX = r"^/api/.*$"
Setting Default Meaning

CORS_ALLOWED_ORIGINS

[]

Exact origins (scheme, host and port) that may call the API.

CORS_ALLOWED_ORIGIN_REGEXES

[]

Regular expressions for origins; anchor them carefully, like any pattern that decides trust.

CORS_ALLOW_ALL_ORIGINS

False

Allow every origin; sends * unless credentials are allowed, in which case the request’s own origin is echoed. Use only for genuinely public APIs.

CORS_ALLOW_CREDENTIALS

False

Sends Access-Control-Allow-Credentials: true so cookies and authorization headers are accepted from allowed origins.

CORS_URLS_REGEX

r"^.*$"

Only paths matching are CORS-enabled; restrict it to the API prefix.

CORS_ALLOW_METHODS, CORS_ALLOW_HEADERS

the package defaults

Methods and request headers advertised in the preflight answer; add custom headers (such as x-csrftoken) when needed.

CORS_EXPOSE_HEADERS

[]

Response headers that scripts may read.

CORS_PREFLIGHT_MAX_AGE

86400

Seconds a browser may cache the preflight result.

CORS_ALLOW_PRIVATE_NETWORK

False

Answers private-network-access preflights.

CORS tests (catalog/tests.py)
class CorsTests(TestCase):
    def test_allowed_and_denied_origin(self):
        ok = self.client.options("/api/reviews/", headers={
            "Origin": "https://app.example.com",
            "Access-Control-Request-Method": "POST",
            "Access-Control-Request-Headers": "x-csrftoken, content-type",
        })
        self.assertEqual(ok["Access-Control-Allow-Origin"], "https://app.example.com")
        self.assertIn("x-csrftoken", ok["Access-Control-Allow-Headers"])
        denied = self.client.get("/api/reviews/", headers={"Origin": "https://evil.example.net"})
        self.assertNotIn("Access-Control-Allow-Origin", denied)
        outside = self.client.get("/greeting/", headers={"Origin": "https://app.example.com"})
        self.assertNotIn("Access-Control-Allow-Origin", outside)

CORS and CSRF solve different problems. CORS only controls which origins' scripts may read responses; it does not stop a cross-site form post, which the browser sends anyway. A cross-origin AJAX client that authenticates with the session cookie therefore needs all of: the origin in CORS_ALLOWED_ORIGINS, CORS_ALLOW_CREDENTIALS = True, the same origin in CSRF_TRUSTED_ORIGINS (with its scheme), an appropriate SameSite cookie setting and the X-CSRFToken header; the test_trusted_origin_is_accepted test above exercises the CSRF half. The middleware is both sync and async capable.

Logging security events

Security failures are logged to dedicated loggers: django.security.csrf for CSRF rejections and one django.security.<ExceptionName> logger per SuspiciousOperation subtype (DisallowedHost, RequestDataTooBig, TooManyFields, SuspiciousFileOperation, and so on). A SuspiciousOperation that ends in a 400 is logged to the security logger only, not to django.request. By default these events reach the django logger, which mails ADMINS when DEBUG is False; route them to your log pipeline and alert on spikes instead, and silence a noisy one with a NullHandler and propagate: False for that single logger name.

Sending the security loggers to a handler (config/settings.py, excerpt)
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {
        "django.security": {"handlers": ["console"], "level": "WARNING"},
    },
}

The tests above use assertLogs("django.security.csrf"), assertLogs("django.security.DisallowedHost") and assertLogs("django.security.RequestDataTooBig") to assert that the events are emitted. Do not log secrets: request bodies, tokens and cookies must stay out of log records, and the include_html option of AdminEmailHandler sends full tracebacks with settings, so enable it only for trusted recipients.

check --deploy

manage.py check --deploy adds the deployment checks to the normal system checks and is the quickest automated review of the settings above. Run it against the production settings module, not the development one, in CI, with the production environment variables set. --fail-level WARNING makes warnings fail the command (the default fails only on errors), --tag security restricts it to one tag, --list-tags --deploy lists tags (including deployment-only ones such as security) and --database runs the database checks for the named aliases.

Production settings (config/settings_production.py)
from .settings import *  # noqa: F401,F403

DEBUG = False

# HTTPS everywhere: redirect, secure cookies, HSTS.
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000  # one year; start with 3600 while testing
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# Behind a reverse proxy that terminates TLS, always sets this header itself
# and strips any copy sent by the client.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# Headers that SecurityMiddleware and XFrameOptionsMiddleware send.
SECURE_REFERRER_POLICY = "same-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

With the development settings and DJANGO_DEBUG=1 the command reports five security warnings:

$ DJANGO_DEBUG=1 python manage.py check --deploy
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. ...
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. ...
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. ...
?: (security.W018) You should not have DEBUG set to True in deployment.

System check identified 5 issues (0 silenced).

With the production module the output is clean, and a weak key is caught:

$ DJANGO_ALLOWED_HOSTS=www.bookshelf.example python manage.py check --deploy --settings=config.settings_production
System check identified no issues (0 silenced).

$ DJANGO_SECRET_KEY=django-insecure-short DJANGO_ALLOWED_HOSTS=www.bookshelf.example \
    python manage.py check --deploy --settings=config.settings_production --fail-level WARNING
SystemCheckError: System check identified some issues:

WARNINGS:
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' ...
Check What it verifies

security.W001, W002, W003

SecurityMiddleware, XFrameOptionsMiddleware and CsrfViewMiddleware are in MIDDLEWARE.

security.W004, W005, W021

HSTS is configured: SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS, SECURE_HSTS_PRELOAD.

security.W006

SECURE_CONTENT_TYPE_NOSNIFF is true.

security.W008

SECURE_SSL_REDIRECT is true (or you redirect in a proxy; silence the check).

security.W009, W025

SECRET_KEY and each SECRET_KEY_FALLBACKS entry have at least 50 characters, 5 distinct characters and no django-insecure- prefix.

security.W010 to W012, W016

SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE are true.

security.W013 to W015

SESSION_COOKIE_HTTPONLY is true.

security.W018

DEBUG is false.

security.W019

X_FRAME_OPTIONS is 'DENY' when the middleware is on.

security.W020

ALLOWED_HOSTS is not empty.

security.W022, E023, E024

SECURE_REFERRER_POLICY is set and valid; SECURE_CROSS_ORIGIN_OPENER_POLICY is valid.

security.E101, E102

CSRF_FAILURE_VIEW is importable and takes the right arguments.

security.E026, W027

The CSP settings have the right shape; since 6.1, a policy using CSP.NONCE without the context processor (see the CSP page).

A passing check is not a security audit: it cannot know that a proxy strips X-Forwarded-Proto, that your upload directory is not executable, or that a view forgets authorisation. Silence a warning only with a comment explaining who provides the protection instead (SILENCED_SYSTEM_CHECKS = ["security.W008"] when the load balancer redirects).

Sync and async notes

  • Every middleware discussed here (SecurityMiddleware, CsrfViewMiddleware, XFrameOptionsMiddleware, CorsMiddleware) works in both a synchronous and an asynchronous stack; mixing a sync-only third-party middleware into an ASGI project forces a thread hop for the rest of the chain (see Middleware).

  • csrf_exempt, csrf_protect, ensure_csrf_cookie, requires_csrf_token and the xframe_options_* decorators can wrap async def views since 5.0; the AsyncCsrfTests test exercises one. login_required and its relatives gained the same support in 5.1.

  • Under ASGI the entire request body may be spooled to disk before DATA_UPLOAD_MAX_MEMORY_SIZE or any file validation is applied, so request-size limits belong in front of Django (the ASGI server or proxy) too.

  • The SQL-injection rules are the same in async code: the asynchronous queryset methods (aget(), acreate() and async for over a queryset) use the same parameterised queries, and calling the synchronous ORM from an async def view is a separate bug (it raises SynchronousOnlyOperation).

  • request.get_host() and ALLOWED_HOSTS behave the same on ASGI.

Django’s security policy and release process

Django treats security as a first-class release type, and an application inherits that only if it upgrades.

Reporting a vulnerability

Report suspected vulnerabilities privately to security@djangoproject.com (an optional PGP key is published), not on the public ticket tracker. You should get an acknowledgement within 3 working days, analysis can take weeks, and reports aim to be resolved within 90 days, faster for high-severity confirmed issues. Be mindful of volunteer time: send a brief description, a minimal working proof of concept, the Django and Python versions, and optionally a patch; leave out CVSS scores and long background, and wait for triage before sending more reports. The policy also states what is not a valid report:

  • failures to sanitise user input (the send_mail() and extra() examples above);

  • issues that only occur on an end-of-life Python or dependency, or only on runserver;

  • proof of concepts that need request headers or URLs over 8 KB, a request body over 2.5 MB, or DTL template content over 100 KB, because production servers and defaults already limit them;

  • code that calls private or undocumented functions directly, or needs uncommon or insecure management-command options;

  • unverified AI-generated reports: tools used must be disclosed and the issue must be real and reproducible, otherwise the report is closed.

Supported versions and severity

Security fixes go to the main development branch, to the two most recent release series and to any LTS. As of 2026-10-03 that means 6.1 (mainstream support until April 2027, extended to December 2027), 6.0 (until April 2027) and the 5.2 LTS (extended until April 2028). Severity follows the attack type: high for remote code execution and SQL injection, moderate for XSS, CSRF and broken authentication, low for denial of service, sensitive data exposure, broken session management, unvalidated redirects and issues needing an uncommon configuration, and the team may adjust a level (a default-configuration, unauthenticated denial of service can be raised to moderate).

How a release is announced, and what to do

About a week before disclosure, Django announces the date, time and severity on the django-announce list (subscribe to it, it is the notice for everyone, without the details); distributors get the details privately. On the day, the patch is applied, new releases are published on PyPI and the website, and the issue is described on the Django weblog and the django-announce and oss-security lists. The practical rules for a project are therefore:

  • subscribe to django-announce and watch the release schedule;

  • pin Django to a supported minor series and take every patch release immediately, because patch releases contain only fixes (the 6.1.2 release notes page already lists one low and three moderate security issues for 2026-10-06);

  • stay on a supported series: older releases are not investigated and receive no patches;

  • upgrade dependencies too (Pillow, the database driver, the ASGI server, DRF); security releases sometimes fix popular third-party packages;

  • keep a short incident plan: who can rotate SECRET_KEY (with SECRET_KEY_FALLBACKS), revoke sessions and redeploy.

Pitfalls checklist

  • mark_safe() on an f-string, .format() result or % result. Use format_html().

  • Unquoted attribute values in templates.

  • csrf_exempt for convenience. It disables protection for that view; webhooks need a signature check instead.

  • Posting a form to an external URL with {% csrf_token %} inside. The token leaks.

  • Forgetting ensure_csrf_cookie on a page that uses AJAX but renders no form.

  • Cache decorators above csrf_protect. The cached response has no cookie or Vary.

  • fields = "all" or exclude in a ModelForm or serializer.

  • Client-chosen field names in order_by(), filter(**params) or values().

  • SQL built with f-strings or %. Pass params.

  • ALLOWED_HOSTS = [""]* without your own validation, or reading request.META["HTTP_HOST"].

  • SECURE_PROXY_SSL_HEADER without a proxy that strips the header. It lets attackers fake HTTPS.

  • Enabling HSTS with a long age, includeSubDomains and preload before every subdomain is HTTPS-only.

  • Trusting ImageField or the extension as proof that a file is harmless; serve uploads as attachments or from another domain.

  • Referrer policy no-referrer breaking CSRF on HTTPS.

  • Running check --deploy against the development settings, or ignoring warnings without a comment.

  • DEBUG = True, a committed SECRET_KEY or a django-insecure- key in production.

  • CORS with credentials*, or using CORS as if it protected against CSRF.

  • Staying on an unsupported Django series.

What changed recently

Version Change

6.1

security.W027 warns when a CSP policy uses CSP.NONCE without the csp() context processor; 6.1.2 (scheduled 2026-10-06) is a security release for the supported series.

6.0

Built-in Content Security Policy (ContentSecurityPolicyMiddleware, SECURE_CSP, SECURE_CSP_REPORT_ONLY, the csp() context processor); calling format_html() without arguments was removed (deprecated in 5.0).

5.1

LoginRequiredMiddleware; login_required and friends wrap async views.

5.0

csrf_exempt, csrf_protect, ensure_csrf_cookie, requires_csrf_token and xframe_options_* wrap async views; the CSRF_COOKIE_MASKED transitional setting was removed; LogoutView accepts only POST.

4.1

SECRET_KEY_FALLBACKS; SECURE_PROXY_SSL_HEADER accepts a comma-separated list; mark_safe() preserves lazy objects; the CSRF cookie is no longer masked (transitional CSRF_COOKIE_MASKED).

4.0

CsrfViewMiddleware checks Origin; CSRF_TRUSTED_ORIGINS entries need a scheme; SecurityMiddleware adds Cross-Origin-Opener-Policy: same-origin by default; SECURE_BROWSER_XSS_FILTER and X-XSS-Protection were removed.

3.1

SECURE_REFERRER_POLICY defaults to 'same-origin'; CSRF_COOKIE_SAMESITE accepts 'None'.

3.0

SECURE_CONTENT_TYPE_NOSNIFF defaults to True; SecurityMiddleware can send Referrer-Policy; the private is_safe_url() became the public url_has_allowed_host_and_scheme().

1.8

SecurityMiddleware and its settings (HSTS, SSL redirect, nosniff) integrated from the third-party django-secure package.

Where the books are outdated

The books that informed this section were written for Django releases between 1.11 and 5.x; the security advice in them is mostly sound, but several specifics have moved on.

  • Settings that no longer exist. SECURE_BROWSER_XSS_FILTER and the X-XSS-Protection header (removed in 4.0, because browsers ignore the header) and CSRF_COOKIE_HTTPONLY as a protection (the documentation now says it gives little practical benefit) appear in older checklists.

  • Third-party packages that became core. django-secure is core since 1.8 (SecurityMiddleware), and django-csp is superseded by the built-in Content Security Policy in 6.0; see the CSP page before adding a package.

  • Renamed helpers. is_safe_url() is url_has_allowed_host_and_scheme() since 3.0, and url(), ugettext and force_text snippets in older examples no longer import.

  • Defaults changed. SECURE_CONTENT_TYPE_NOSNIFF is on by default (3.0), SECURE_REFERRER_POLICY defaults to 'same-origin' (3.1), Cross-Origin-Opener-Policy is sent by default (4.0) and CSRF_TRUSTED_ORIGINS needs schemes (4.0); older text that lists them as optional extras or without schemes is out of date.

  • Logout over GET. Older examples link to a logout URL; since 5.0 LogoutView accepts only POST, which closes a CSRF hole (a logout triggered by an image tag).

  • Key rotation. Older text recommends replacing SECRET_KEY and invalidating every session; SECRET_KEY_FALLBACKS (4.1) allows a graceful rotation.

  • Examples that are themselves vulnerable. Some textbook examples fetch a user-supplied URL server-side (SSRF), build HTML from Markdown with mark_safe() and no sanitising, assign innerHTML from API data, expose a viewset whose queryset is not filtered by owner, or use fields = "all"; none of those patterns should be copied.