Practical, Django-specific defenses against the OWASP Top 10. Real attack examples and the exact code, settings, and tools you need to prevent them in production.
Django ships with solid security defaults — but they don't cover everything. Here's how each OWASP Top 10 risk maps to Django, and exactly what you should configure, write, or avoid.
The #1 vulnerability. Attackers access things they shouldn't.
# Bad: returns any order if you know the ID
def order_detail(request, order_id):
order = Order.objects.get(pk=order_id)
return render(request, 'order.html', {'order': order})
# Good: scoped to the current user
def order_detail(request, order_id):
order = get_object_or_404(Order, pk=order_id, user=request.user)
return render(request, 'order.html', {'order': order})
Rule: always include the user (or tenant) filter in queries that return user-owned data.
from rest_framework import permissions
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.user == request.user
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsOwner]
def get_queryset(self):
return Order.objects.filter(user=self.request.user)
Harder to enumerate:
import uuid
class Order(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# settings/production.py
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
Django uses PBKDF2 by default. Use Argon2 (stronger):
# pip install argon2-cffi
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher', # fallback
]
# pip install django-cryptography
from django_cryptography.fields import encrypt
class UserProfile(models.Model):
ssn = encrypt(models.CharField(max_length=11))
SECRET_KEY = os.environ['SECRET_KEY']
SECRET_KEY_FALLBACKS = [os.environ.get('OLD_SECRET_KEY')]
Django's ORM is safe by default, but raw SQL and extra() are not:
# Bad: f-string in raw SQL
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")
# Good: parameterized
User.objects.raw("SELECT * FROM users WHERE name = %s", [name])
# Safer: use the ORM
User.objects.filter(name=name)
import subprocess
# Bad: shell injection possible
subprocess.run(f"convert {filename} output.png", shell=True)
# Good: argument list, no shell
subprocess.run(['convert', filename, 'output.png'])
Never render user input as a template:
# Bad: user can inject Django template syntax
from django.template import Template, Context
Template(user_input).render(Context({}))
# Good: use Django templates ONLY for your own templates
Design flaws you can't patch. Examples: - No rate limiting on login → brute force - Password reset tokens that don't expire - Weak session IDs - No account lockout
# pip install django-ratelimit
from django_ratelimit.decorators import ratelimit
@ratelimit(key='post:email', rate='5/m', method='POST', block=True)
def login_view(request):
...
Or nginx-level:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location /login/ {
limit_req zone=login burst=10 nodelay;
proxy_pass http://django;
}
from django.contrib.auth.tokens import PasswordResetTokenGenerator
# Django's default tokens expire in 3 days and are one-time-use
# Configurable:
PASSWORD_RESET_TIMEOUT = 60 * 15 # 15 minutes
# Never!
DEBUG = True # exposes tracebacks, leaks SECRET_KEY
# Production
DEBUG = False
ALLOWED_HOSTS = ['example.com', 'www.example.com']
Move it:
# urls.py
urlpatterns = [
path('secure-admin-9f2a/', admin.site.urls), # non-guessable
]
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY'
SECURE_REFERRER_POLICY = 'same-origin'
# Content Security Policy (via django-csp)
# pip install django-csp
CSP_DEFAULT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", 'https://fonts.googleapis.com')
CSP_SCRIPT_SRC = ("'self'",) # no unsafe-inline
python manage.py check --deploy
# Scan for known CVEs
pip install pip-audit
pip-audit
# Auto-update with Dependabot (GitHub)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
Check last release date on PyPI. If it's been 2+ years with no update, consider alternatives.
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'},
]
# pip install django-otp django-two-factor-auth
INSTALLED_APPS = [..., 'django_otp', 'two_factor']
MIDDLEWARE = [..., 'django_otp.middleware.OTPMiddleware']
LOGIN_URL = 'two_factor:login'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax' # or 'Strict'
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_AGE = 60 * 60 * 2 # 2 hours
# Never unpickle untrusted data — RCE via pickle is trivial
import pickle
pickle.loads(untrusted_data) # 🚨
# Use JSON instead
import json
json.loads(untrusted_data) # safe
from django.core.signing import Signer, BadSignature
signer = Signer()
signed = signer.sign_object({'user_id': 42})
# ... later
try:
obj = signer.unsign_object(signed)
except BadSignature:
raise PermissionDenied
You can't respond to incidents you can't see.
LOGGING = {
'version': 1,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
},
'formatters': {
'json': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'format': '%(asctime)s %(levelname)s %(name)s %(message)s',
},
},
'loggers': {
'django.security': {
'handlers': ['console'],
'level': 'INFO',
},
},
}
from django.contrib.auth.signals import user_logged_in, user_login_failed
@receiver(user_login_failed)
def log_failed_login(sender, credentials, **kwargs):
logger.warning("Failed login", extra={
'username': credentials.get('username'),
'ip': request.META.get('REMOTE_ADDR'),
})
# pip install sentry-sdk
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=os.environ['SENTRY_DSN'],
integrations=[DjangoIntegration()],
send_default_pii=False, # GDPR
)
Never fetch URLs the user controls without validation:
import requests
from urllib.parse import urlparse
def fetch_user_url(url):
parsed = urlparse(url)
# Block internal IPs
if parsed.hostname in ('localhost', '127.0.0.1', '169.254.169.254'):
raise ValueError("Internal URL blocked")
# Resolve and check IP
ip = socket.gethostbyname(parsed.hostname)
if ipaddress.ip_address(ip).is_private:
raise ValueError("Private IP blocked")
# Whitelist protocols
if parsed.scheme not in ('http', 'https'):
raise ValueError("Only http/https allowed")
return requests.get(url, timeout=5, allow_redirects=False)
# Django's own security check
python manage.py check --deploy
# Dependency vulnerabilities
pip-audit
# Secret scanning
trufflehog --regex --entropy=False .
# Static analysis
bandit -r .
# Settings audit
pip install django-lint
django-lint settings.py
Security isn't a one-time task. Apply defense in depth:
The goal isn't zero risk — it's making you a harder target than the next app.