Web Security

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.

March 9, 20265 min readShipSafer Team

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_HOSTS explicitly set
  • All SECURE_* settings enabled
  • SESSION_COOKIE_SECURE = True
  • CSRF_COOKIE_SECURE = True
  • SECRET_KEY loaded from environment variable
  • Password validators configured
  • No mark_safe() on user input
  • No raw SQL with string interpolation
  • python manage.py check --deploy passes with no warnings
  • Dependency audit: pip-audit or safety check

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.