Django Security Guide: CSRF, SQL Injection, and Hardening Settings
Harden Django applications with SECURE_* settings, CSRF_COOKIE_HTTPONLY, parameterized ORM queries, SECRET_KEY rotation, and DEBUG=False checklists.
Django Security Guide: CSRF, SQL Injection, and Hardening Settings
Django includes more built-in security features than almost any other web framework — but "included" does not mean "enabled." Several critical protections are off by default or depend on correct configuration. This guide covers every setting and pattern you need to lock down a production Django application.
The DEBUG = False Prerequisite
Before anything else: never run DEBUG = True in production. Debug mode:
- Renders full stack traces (with local variables) to the browser on every unhandled exception
- Disables many security checks
- Serves static files directly through Django (slow and insecure)
# settings/production.py
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
Django will refuse to start without ALLOWED_HOSTS when DEBUG is False — this is intentional.
The Complete SECURE_* Settings Block
Django ships with a collection of HTTP security header settings. Apply all of them:
# settings/production.py
# Force HTTPS
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000 # 2 years
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Prevent clickjacking
X_FRAME_OPTIONS = 'DENY'
# Prevent MIME sniffing
SECURE_CONTENT_TYPE_NOSNIFF = True
# XSS filter (legacy browsers)
SECURE_BROWSER_XSS_FILTER = True
# Cookie security
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Lax'
Run Django's built-in security check before every deployment:
python manage.py check --deploy
This command audits your settings and reports any security misconfigurations.
CSRF Protection
Django's CSRF middleware is enabled by default in MIDDLEWARE, but it requires careful handling in AJAX-heavy applications.
Traditional Forms
The {% csrf_token %} template tag handles this automatically:
<form method="post" action="/transfer/">
{% csrf_token %}
<input type="hidden" name="amount" value="100">
<button type="submit">Transfer</button>
</form>
AJAX / Fetch Requests
Read the CSRF token from the cookie and send it as a header:
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch('/api/data/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({ key: 'value' }),
});
API Endpoints (DRF)
If you use Django REST Framework with token or session authentication, you can exempt specific views from CSRF using @csrf_exempt only when you have alternative authentication verification in place. Do not blanket-exempt your entire API.
Preventing SQL Injection
Django's ORM uses parameterized queries by default. You are safe as long as you use the ORM correctly:
# Safe — ORM parameterizes automatically
User.objects.filter(email=user_input)
User.objects.filter(username__icontains=search_term)
# DANGEROUS — raw string interpolation
from django.db import connection
cursor = connection.cursor()
cursor.execute(f"SELECT * FROM users WHERE email = '{user_input}'") # SQL injection
# Safe raw query — use parameters
cursor.execute("SELECT * FROM users WHERE email = %s", [user_input])
The danger zones are extra(), RawSQL(), and raw(). When you must use them, always use parameter binding:
# Safe use of extra()
queryset = User.objects.extra(
where=["email = %s"],
params=[user_input]
)
# Safe use of RawSQL
from django.db.models.expressions import RawSQL
User.objects.annotate(
lower_email=RawSQL("LOWER(email)", [])
)
Password Hashing
Django uses PBKDF2 with SHA-256 by default. For applications with stricter requirements, switch to Argon2:
pip install argon2-cffi
# settings.py
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher', # fallback for existing hashes
]
Enforce minimum password complexity:
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'},
]
SECRET_KEY Rotation
The SECRET_KEY signs sessions, CSRF tokens, and password reset links. Compromising it allows an attacker to forge any of these.
Load from Environment, Never Hardcode
# settings/base.py
import os
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
Rotation Strategy (Django 4.1+)
Django 4.1 introduced SECRET_KEY_FALLBACKS, which allows graceful rotation without invalidating existing sessions:
SECRET_KEY = os.environ['DJANGO_SECRET_KEY_NEW']
SECRET_KEY_FALLBACKS = [
os.environ['DJANGO_SECRET_KEY_OLD'],
]
Deploy with the new key in SECRET_KEY and the old one in SECRET_KEY_FALLBACKS. After all old sessions have expired (typically 2 weeks), remove the fallback.
User Input and XSS
Django's template engine auto-escapes HTML by default. The risk is mark_safe() and the | safe filter:
# DANGEROUS
from django.utils.safestring import mark_safe
return mark_safe(f"<p>{user_comment}</p>") # XSS if user_comment contains <script>
# Safe — let Django escape
context = {'comment': user_comment}
# In template: {{ comment }} — auto-escaped
If you must render HTML from users (rich text editors, etc.), use bleach:
pip install bleach
import bleach
ALLOWED_TAGS = ['b', 'i', 'u', 'em', 'strong', 'a', 'p', 'br']
ALLOWED_ATTRS = {'a': ['href', 'rel']}
clean_html = bleach.clean(user_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS)
File Upload Security
Validate file types server-side — never trust the Content-Type header:
import magic
def validate_image(file):
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ['image/jpeg', 'image/png', 'image/webp']:
raise ValidationError('Invalid file type')
Store uploaded files outside the web root and serve them through a view that checks permissions, or use a signed URL with S3/GCS.
Rate Limiting
Django does not include rate limiting out of the box. Add django-ratelimit:
pip install django-ratelimit
from django_ratelimit.decorators import ratelimit
@ratelimit(key='ip', rate='5/m', method='POST', block=True)
def login_view(request):
# max 5 POST requests per IP per minute
...
Security Middleware Order
The order of MIDDLEWARE matters. SecurityMiddleware must come first:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # must be first
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
Pre-Deployment Checklist
-
DEBUG = False -
ALLOWED_HOSTSexplicitly set - All
SECURE_*settings enabled -
SESSION_COOKIE_SECURE = True -
CSRF_COOKIE_SECURE = True -
SECRET_KEYloaded from environment variable - Password validators configured
- No
mark_safe()on user input - No raw SQL with string interpolation
-
python manage.py check --deploypasses with no warnings - Dependency audit:
pip-auditorsafety check