Essential security practices for Django applications. Covers CSRF, XSS, SQL injection, HTTPS, headers, authentication, and deployment hardening.
Django is secure by default in many ways, shipping with protections against common attacks — but those defaults can be disabled, misconfigured, or undermined by application code, and several critical settings must be configured correctly for production. This practical checklist walks through the security essentials every Django deployment should get right, from the settings that matter most to the habits that keep an application safe over time.
Django was built with security as a core concern and provides strong protections out of the box — against cross-site scripting, cross-site request forgery, SQL injection, and more. This is a major reason to build on it: many risks are handled for you by default. But "secure by default" is not "secure no matter what," because every default can be turned off or worked around by application code, and some critical settings are not safe by default for production. The goal of a security checklist is to ensure you keep the protections Django gives you and correctly configure the things that are your responsibility.
The single most important production setting is disabling debug mode. With debug on, Django shows detailed error pages that expose stack traces, configuration, and internal details — invaluable in development and dangerous in production, where they hand an attacker a map of your application. Debug mode must be off in production, without exception. This is one of the most common and most serious misconfigurations, precisely because debug is on by default for development convenience and must be deliberately turned off for deployment. Verifying that debug is disabled in production is the first item on any Django security checklist.
The most reliable way to keep debug off is to make "off" what happens when nobody configures anything. Read the flag from the environment and treat anything other than an explicit opt-in as false. A settings file that defaults to DEBUG = True and relies on someone remembering to override it in production will eventually ship with debug on, usually after a hurried redeploy or a new server that was provisioned from an old template.
# myproject/settings.py
import os
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"
With this pattern a missing or misspelled variable fails safe: the site runs without debug. Developers set DJANGO_DEBUG=1 in their local .env file, and production never sets it at all. Avoid parsing the value with bool(os.environ.get(...)) — any non-empty string, including "False" and "0", is truthy in Python, which turns a well-intentioned DJANGO_DEBUG=False into debug mode.
Django uses a secret key to sign sessions, tokens, and other security-sensitive data, and the security of those depends entirely on the key staying secret. It must be a strong, random value, it must never be committed to version control, and it should be loaded from the environment rather than hard-coded in settings. A leaked secret key undermines the protections that rely on it. Treating the secret key as the critical credential it is — kept out of source control, stored securely, and rotated if ever exposed — is essential, because so much of Django's security signing depends on its confidentiality.
Since Django 4.1 you can rotate the key gracefully using SECRET_KEY_FALLBACKS. Django signs new data with SECRET_KEY but still accepts signatures made with any key in the fallback list, so existing sessions and password-reset links keep working during the transition:
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
SECRET_KEY_FALLBACKS = [
k for k in os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "").split(",") if k
]
For routine rotation, move the current key into the fallbacks, deploy a new primary key, and remove the old key from the fallbacks after your session lifetime has passed. For a leaked key the calculus is different: an attacker holding the key can forge anything signed with it, so you should not keep it as a fallback. Replace it outright, accept that users will be logged out, and then find and close the path through which it leaked — a repository, a log, or an error page served with debug on — or the new key will leak the same way.
The allowed hosts setting tells Django which host or domain names your site is allowed to serve, and it must be set to your actual domains in production. It defends against certain attacks that rely on a forged host header, and leaving it open or misconfigured weakens that protection. Setting it explicitly to the domains your application should respond to is a small configuration step with a real security purpose. It is part of the baseline production configuration that, together with disabling debug and protecting the secret key, forms the foundation of a securely deployed Django application.
All traffic should be encrypted with HTTPS, and Django offers settings to enforce it — redirecting plain HTTP to HTTPS, marking cookies as secure so they are only sent over encrypted connections, and enabling strict transport security so browsers refuse to connect over plain HTTP. Without HTTPS, data including credentials and session cookies travels in the clear, readable by anyone on the network path. Enforcing HTTPS end to end, with the related security settings enabled, is non-negotiable for any production site, and modern certificate tooling makes it free and straightforward to set up.
# myproject/settings.py (production)
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600 # start small, raise later
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False # only after you are sure
# Only if a reverse proxy terminates TLS and ALWAYS sets this header
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
| Setting | Default | Effect |
|---|---|---|
SECURE_SSL_REDIRECT | False | Redirects every HTTP request to HTTPS in SecurityMiddleware. |
SESSION_COOKIE_SECURE | False | Browser only sends the session cookie over HTTPS. |
CSRF_COOKIE_SECURE | False | Same for the CSRF cookie. |
SECURE_HSTS_SECONDS | 0 | Sends Strict-Transport-Security; browsers refuse plain HTTP for that many seconds. |
SECURE_HSTS_INCLUDE_SUBDOMAINS | False | Extends HSTS to every subdomain. |
SECURE_HSTS_PRELOAD | False | Adds the preload directive required for browser preload lists. |
Most deployments terminate TLS at nginx or a load balancer and forward plain HTTP to Gunicorn. Django then sees every request as insecure, so SECURE_SSL_REDIRECT redirects to HTTPS, the proxy forwards the HTTPS request as HTTP again, and the browser reports an infinite redirect loop. SECURE_PROXY_SSL_HEADER fixes this by telling Django to trust a header from the proxy. The proxy must set that header on every request and overwrite any value the client sent:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Never set SECURE_PROXY_SSL_HEADER if the application can be reached directly, without the proxy in front: a client could then send the header itself and make a plain-HTTP request look secure. Bind Gunicorn to 127.0.0.1 or a private address such as 10.0.0.5, not a public interface.
Django's protections against cross-site request forgery and cross-site scripting work by default — CSRF tokens guard form submissions, and template auto-escaping neutralizes injected scripts — but they can be defeated by application code that opts out. The danger arises when you disable CSRF protection on a view or mark untrusted content as safe so it renders as raw HTML. The checklist item here is to respect these defaults: keep CSRF protection enabled and never mark user-supplied content as safe without sanitizing it. The strongest protections are the ones you do not accidentally turn off, and these two defend against two of the most common web attacks.
Auto-escaping is defeated by the |safe filter, {% autoescape off %}, and mark_safe() in Python. When you need to build HTML in code, use format_html(), which escapes every argument while leaving the template string intact:
from django.utils.html import format_html
# Unsafe: user-controlled name becomes raw HTML
# return mark_safe(f'<a href="/u/{user.pk}/">{user.first_name}</a>')
# Safe: arguments are escaped
def user_link(user):
return format_html('<a href="/u/{}/">{}</a>', user.pk, user.first_name)
If users must supply rich text — comments with formatting, a profile bio — sanitize it with an allowlist-based HTML sanitizer such as nh3 at the time you save or render it, permitting only a small set of tags and attributes. Escaping is also context-dependent: auto-escaping makes values safe inside HTML element content and quoted attributes, but not inside a <script> block or an unquoted attribute. To pass data to JavaScript, use the json_script template filter, which emits a JSON <script type="application/json"> element that your code reads with JSON.parse, rather than interpolating template variables into JavaScript source.
Django's ORM parameterizes queries, so ordinary ORM use is safe against SQL injection by construction — a major built-in protection. The risk returns only when you bypass the ORM with raw SQL built by concatenating user input, where unsanitized input can become injectable. The checklist guidance is simple: use the ORM's safe query construction and never build SQL by stitching user input into a query string. As long as you stay within the ORM's parameterized queries and treat any raw SQL with great care, this entire category of attack is effectively closed for you by the framework.
When you do need raw SQL — a reporting query, a database-specific feature — pass values as parameters and let the database driver quote them. The difference is often a single character in the code, which is why it is worth checking carefully in review:
from django.db import connection
# Vulnerable: string formatting puts user input into the SQL text
# cursor.execute(f"SELECT id FROM shop_order WHERE status = '{status}'")
# Safe: placeholder plus a parameter list
with connection.cursor() as cursor:
cursor.execute(
"SELECT id, total FROM shop_order WHERE status = %s AND created >= %s",
[status, since],
)
rows = cursor.fetchall()
# Same rule for Manager.raw()
orders = Order.objects.raw("SELECT * FROM shop_order WHERE customer_id = %s", [customer_id])
Note that the placeholder is %s without quotes around it — quoting the placeholder yourself breaks the parameterization. The same rule applies to RawSQL() expressions and the legacy QuerySet.extra(), which should be avoided in new code.
Authentication is the front door, and several practices harden it. Enforce strong passwords using Django's password validators, protect against brute-force attempts with rate limiting and lockouts on repeated failures, secure session cookies, and offer or require multi-factor authentication for sensitive or administrative accounts. Django provides solid building blocks for all of this, but you must enable and tune them for your threat model rather than accepting bare defaults. Hardening authentication is high-value because it directly defends against attackers trying to gain access with stolen or guessed credentials, which is one of the most common attack methods.
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 12}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Optional: Argon2 first (requires the argon2-cffi package)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
Validators run in Django's own password forms and in createsuperuser, but not automatically in your custom registration code or API serializers. If you build your own signup flow, call django.contrib.auth.password_validation.validate_password(password, user) explicitly. Keeping PBKDF2 in the hasher list after Argon2 means existing hashes still verify and are transparently upgraded to the first hasher the next time each user logs in.
A large part of your security depends on code you did not write — Django itself and your other dependencies. Known vulnerabilities in outdated packages are exactly what automated attacks look for, so keeping Django current with its security releases and updating dependencies promptly when fixes are published is essential. Scanning your dependencies for known vulnerabilities and applying updates is ongoing hygiene rather than a one-time task. The most carefully written application can still be compromised through a single neglected, vulnerable dependency, which is why staying current is a recurring item on every security checklist.
Django includes a built-in deployment check that audits your configuration for many common security issues — debug left on, an insecure secret key, missing HTTPS settings, and more — and reports what to fix. Running it as part of your deployment process catches a great deal of the low-hanging fruit automatically, before it reaches production. This tool turns much of this checklist into an automated verification, so a forgotten setting is flagged rather than silently shipped. Making the deployment check a routine step is one of the easiest, highest-value habits for keeping a Django application's configuration secure.
The check must run with the settings that production actually uses — running it against development settings will happily report everything as fine or everything as broken, neither of which is useful:
DJANGO_SETTINGS_MODULE=myproject.settings.production \
python manage.py check --deploy --fail-level WARNING
--fail-level WARNING makes the command exit with a non-zero status on warnings, not just errors, which is what you want in a CI pipeline. Typical findings and their meaning:
| Check ID | Meaning |
|---|---|
security.W004 | SECURE_HSTS_SECONDS is not set. |
security.W008 | SECURE_SSL_REDIRECT is not True. |
security.W009 | SECRET_KEY is short, low-entropy, or looks auto-generated with the insecure prefix. |
security.W012 | SESSION_COOKIE_SECURE is not True. |
security.W016 | CSRF_COOKIE_SECURE is not True. |
security.W018 | DEBUG is True. |
security.W020 | ALLOWED_HOSTS is empty. |
Some warnings are legitimately not applicable — for instance, if TLS redirects are handled entirely by the load balancer you may not need SECURE_SSL_REDIRECT. Silence such checks explicitly with SILENCED_SYSTEM_CHECKS = ["security.W008"] and a comment explaining why, rather than ignoring the output; an ignored warning list trains everyone to skip reading it, and the next real finding gets lost among the known ones. Remember the limits of the tool, too: it inspects settings, not your views, so it cannot find a missing ownership check or an unsafe |safe in a template.
An important mindset for the whole checklist is that security comes from layers, not one decisive measure. No single setting makes an application secure; rather, each item closes off a class of risk, and together they form overlapping defenses so that one gap does not mean compromise. HTTPS protects data in transit, the secret key protects signed data, access controls protect resources, and dependency updates protect against known flaws — each addressing a different threat. Approaching security as building up these layers, rather than hunting for a magic bullet, is what produces an application that is genuinely hard to attack, because an attacker who gets past one protection still faces the others.
One of the most important and most application-specific protections is access control: ensuring users can only access the data and actions they are permitted to. Django's authentication identifies who a user is, but authorization — what they may do — is largely your responsibility in each view. The common flaw is fetching an object by an identifier from the URL without checking the user owns it, letting someone access another's data by changing the identifier. Always scoping data to the permitted user and checking permissions on every view that touches user-owned resources closes off this category, which is consistently among the most serious real-world web vulnerabilities.
The fix for insecure direct object references is to include ownership in the query itself, so an object belonging to someone else is simply not found:
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404
@login_required
def invoice_detail(request, pk):
# Vulnerable: any logged-in user can read any invoice by changing pk
# invoice = get_object_or_404(Invoice, pk=pk)
invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
return render(request, "billing/invoice_detail.html", {"invoice": invoice})
Returning 404 rather than 403 for objects the user does not own avoids confirming that the identifier exists. In class-based views, put the scoping in get_queryset() so every view in the hierarchy inherits it:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView, UpdateView
class OwnedInvoiceMixin(LoginRequiredMixin):
def get_queryset(self):
return Invoice.objects.filter(owner=self.request.user)
class InvoiceDetail(OwnedInvoiceMixin, DetailView):
model = Invoice
class InvoiceUpdate(OwnedInvoiceMixin, UpdateView):
model = Invoice
fields = ["notes"]
Notice fields = ["notes"]: listing editable fields explicitly also prevents mass assignment, where a user submits an extra owner or is_paid field and a form that included all fields accepts it. The same care applies to Django REST Framework serializers — declare fields explicitly and mark server-controlled ones read-only. Multi-tenant applications need the same discipline at the organization level: every query should be filtered by the current tenant, ideally through a single helper or custom manager rather than being repeated by hand in each view.
Django 5.1 added LoginRequiredMiddleware, which requires authentication for every view unless it is explicitly marked with the login_not_required decorator. Adding it to MIDDLEWARE flips the default: a new view you forget to protect is private rather than public. It handles authentication only — ownership and permission checks are still your job — but it removes a whole class of "forgot the decorator" mistakes.
Treating all input as potentially hostile is a foundational security habit. Validate data coming from users — through Django's forms and serializers, which provide structured validation — and be careful about how user-supplied content is used and displayed. The framework's protections handle much of this, but your validation logic enforces your application's specific rules and rejects malformed or malicious input before it causes harm. Combined with the built-in protections against injection and scripting, rigorous input validation at your application's boundaries is what keeps untrusted data from becoming a vector for attack, and it is a habit worth applying consistently to every place input enters.
You cannot respond to an attack you cannot see, so logging and monitoring security-relevant events is itself a protection. Recording authentication failures, access denials, and suspicious activity, and watching those signals, turns a silent breach into one you detect and respond to. Without monitoring, an attacker can probe and exploit your application unnoticed for a long time. Building visibility into security events from the start — and ensuring logs themselves are protected and never contain sensitive data — means that when something goes wrong, you find out quickly and have the information to understand and contain it, rather than learning about it from the damage.
The final and most important habit is recognizing that security is not a one-time checklist you complete and forget, but an ongoing practice. New vulnerabilities are discovered, dependencies need updating, new features introduce new risks, and threats evolve. Keeping Django and dependencies patched, reviewing new code for security implications, periodically re-running the deployment check, and staying aware of emerging risks are continuous activities. An application that was secure at launch drifts toward insecurity if neglected. Treating security as a sustained discipline woven into how you develop and operate, rather than a box ticked once, is what keeps an application safe over its whole life.
Django gives you strong security by default, but a secure production deployment requires keeping those protections and configuring the settings that are your responsibility. The essentials: turn debug off in production, protect the secret key and keep it out of source control, set allowed hosts to your real domains, and enforce HTTPS with secure cookies and strict transport security. Respect the built-in CSRF and XSS protections by never disabling them or marking untrusted content as safe, stay within the ORM's parameterized queries to avoid SQL injection, and harden authentication with strong passwords, rate limiting, and multi-factor for privileged accounts. Keep Django and your dependencies patched, since outdated packages are a prime target, and run Django's deployment check to catch misconfigurations automatically. Working through this checklist closes the great majority of real-world risk, because it aligns directly with how applications actually get attacked — and most of it is configuration and habit rather than complex engineering.