Security Advanced

OWASP Top 10 for Django Developers: Practical Security Guide

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.

DjangoZen Team Apr 17, 2026 23 min read 6 views

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.

A01:2021 — Broken Access Control

The #1 vulnerability. Attackers access things they shouldn't.

IDOR (Insecure Direct Object Reference)

# 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.

Object-level permissions in DRF

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)

UUIDs instead of sequential IDs

Harder to enumerate:

import uuid

class Order(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

A02:2021 — Cryptographic Failures

HTTPS everywhere

# 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

Never store passwords in plaintext

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
]

Encrypt sensitive fields

# pip install django-cryptography
from django_cryptography.fields import encrypt

class UserProfile(models.Model):
    ssn = encrypt(models.CharField(max_length=11))

Rotate your SECRET_KEY safely

SECRET_KEY = os.environ['SECRET_KEY']
SECRET_KEY_FALLBACKS = [os.environ.get('OLD_SECRET_KEY')]

A03:2021 — Injection

SQL injection

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)

Command injection

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'])

Template injection

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

A04:2021 — Insecure Design

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

Rate limiting with django-ratelimit

# 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;
}

Secure password reset

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

A05:2021 — Security Misconfiguration

DEBUG in production

# Never!
DEBUG = True  # exposes tracebacks, leaks SECRET_KEY

# Production
DEBUG = False
ALLOWED_HOSTS = ['example.com', 'www.example.com']

Default admin URL

Move it:

# urls.py
urlpatterns = [
    path('secure-admin-9f2a/', admin.site.urls),  # non-guessable
]

Security headers

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

Scan your production settings

python manage.py check --deploy

A06:2021 — Vulnerable Components

Pin and update dependencies

# 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"

Avoid abandoned packages

Check last release date on PyPI. If it's been 2+ years with no update, consider alternatives.

A07:2021 — Authentication Failures

Strong password requirements

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'},
]

Two-factor authentication

# 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 security

SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'  # or 'Strict'
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_AGE = 60 * 60 * 2  # 2 hours

A08:2021 — Software and Data Integrity Failures

Verify pickles... don't

# 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

Sign sensitive cookies

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

A09:2021 — Security Logging and Monitoring Failures

You can't respond to incidents you can't see.

Structured logging

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',
        },
    },
}

Audit log of sensitive actions

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'),
    })

Integrate Sentry

# 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
)

A10:2021 — Server-Side Request Forgery (SSRF)

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)

Pre-Deploy Security Checklist

# 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

Third-Party Tools Worth Using

  • django-axes — brute force login protection
  • django-defender — alternative brute force protection
  • django-honeypot — trap bots on public forms
  • django-csp — Content Security Policy headers
  • django-cors-headers — proper CORS configuration
  • django-ratelimit — rate limiting
  • django-two-factor-auth — 2FA

Summary

Security isn't a one-time task. Apply defense in depth:

  1. Use Django's security features (CSRF, XSS protections, ORM)
  2. Harden settings (HSTS, secure cookies, CSP)
  3. Validate all user input (forms, URLs, uploaded files)
  4. Scope all queries to the current user
  5. Hash passwords with Argon2
  6. Enable 2FA for admins
  7. Rate-limit authentication endpoints
  8. Update dependencies weekly
  9. Monitor and log security events
  10. Run security checks in CI

The goal isn't zero risk — it's making you a harder target than the next app.

Related Tutorials
Ready to Build?

Skip the boilerplate. Get production-ready Django packages.

Browse Products