Django Security Audit
Audit Django applications including DRF (Django REST Framework) APIs.
When this skill applies
- Reviewing Django
settings.py files
- Auditing views, models, and templates
- Reviewing DRF serializers and viewsets
- Checking authentication and permission classes
- Auditing Django Admin exposure
Workflow
Follow ../_shared/audit-workflow.md.
Phase 1: Stack detection
grep -E '^Django|django' requirements.txt pyproject.toml 2>/dev/null
find . -name 'manage.py' -not -path '*/.venv/*'
find . -name 'settings.py' -o -name 'settings/*.py' 2>/dev/null
python -c "import django; print(django.get_version())" 2>/dev/null
Phase 2: Inventory
# Settings files
find . -name 'settings*.py' -not -path '*/.venv/*' -not -path '*/node_modules/*'
# Views
find . -name 'views.py' -o -name 'views/' -type d 2>/dev/null
# URL configurations
find . -name 'urls.py' 2>/dev/null
# Raw SQL usage
grep -rn '\.raw(\|cursor()\|connection\.cursor' . --include='*.py' 2>/dev/null
# Template autoescape disabling
grep -rn '|safe\|autoescape off\|mark_safe\|format_html' . --include='*.py' --include='*.html' 2>/dev/null
Phase 3: Detection — the checks
settings.py audit
- DJG-SET-1
SECRET_KEY from environment variable, not committed:SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
Audit: git log -p settings.py | grep -i secret_key — if any historical commit has a real secret, rotate.
- DJG-SET-2
DEBUG = False in production. Confirmed via env-based settings split:DEBUG = os.environ.get('DJANGO_DEBUG', '0') == '1'
- DJG-SET-3
ALLOWED_HOSTS not ['*'] in production. Specific hostnames listed.
- DJG-SET-4
SECURE_* settings configured:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 (or more)
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True (if you want preload list inclusion)
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER — removed in Django 4 (XSS-Protection deprecated); use CSP instead
X_FRAME_OPTIONS = 'DENY' (or 'SAMEORIGIN')
- DJG-SET-5
CSRF_TRUSTED_ORIGINS lists specific origins (not *).
- DJG-SET-6
DATABASES config uses env vars for credentials.
- DJG-SET-7
EMAIL_* config doesn't have plaintext credentials.
- DJG-SET-8
MIDDLEWARE order:MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # 1st
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', # before CommonMiddleware
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CSRF
- DJG-CSRF-1
CsrfViewMiddleware in MIDDLEWARE.
- DJG-CSRF-2 No
@csrf_exempt on state-changing views unless explicitly needed and documented (webhooks with their own signature verification are OK).
- DJG-CSRF-3 DRF views using
SessionAuthentication automatically apply CSRF. Token/JWT auth doesn't (and shouldn't, for non-cookie auth).
ORM and raw SQL
Mass assignment
- DJG-MA-1
Model.objects.create(**request.POST) patterns flagged. Use forms / serializers with explicit field allowlists.
- DJG-MA-2 DRF
ModelSerializer with fields = '__all__' is mass-assignable. Use explicit fields = ['id', 'name', ...].
- DJG-MA-3 Form
Meta.fields and Meta.exclude reviewed.
Templates
- DJG-TPL-1 Django templates auto-escape by default.
{{ var|safe }} filter and {% autoescape off %} disable it — review every occurrence.
- DJG-TPL-2
mark_safe(...) and format_html(...) in Python code mark strings as safe — confirm content is trusted.
- DJG-TPL-3 SSTI: don't render user input as a template (
Template(user_input).render(...)).
Authentication
- DJG-AUTH-1
AUTH_PASSWORD_VALIDATORS configured with reasonable validators.
- DJG-AUTH-2
PASSWORD_HASHERS first entry is Argon2 or BCrypt (Django default Argon2 is good).
- DJG-AUTH-3 Custom backends (subclasses of
BaseBackend) review authenticate() for timing attacks.
- DJG-AUTH-4
django.contrib.auth.password_validation.validate_password called before set_password.
DRF (Django REST Framework)
- DJG-DRF-1 Global
DEFAULT_PERMISSION_CLASSES set to IsAuthenticated (or stricter); not AllowAny.
- DJG-DRF-2 Per-view permission classes set; no view relying solely on URL routing for auth.
- DJG-DRF-3
ViewSet.queryset filtered to tenant/user scope, not returning all objects.
- DJG-DRF-4 Serializers define
fields explicitly, exclude sensitive (password, is_superuser, user_permissions if not needed).
- DJG-DRF-5 Throttling configured (
DEFAULT_THROTTLE_RATES).
# REST_FRAMEWORK settings.py snippet
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated'],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
'rest_framework.throttling.AnonRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'user': '1000/hour',
'anon': '100/hour',
},
}
File uploads
- DJG-UP-1
FILE_UPLOAD_MAX_MEMORY_SIZE and DATA_UPLOAD_MAX_MEMORY_SIZE set.
- DJG-UP-2
FILE_UPLOAD_PERMISSIONS restrictive (0o644).
- DJG-UP-3
MEDIA_ROOT not within web-served path that could expose uploaded files without auth.
- DJG-UP-4 Uploaded file validated by content (magic bytes), not just
content_type.
Django Admin
- DJG-ADM-1 Admin reachable only by staff users (
is_staff=True).
- DJG-ADM-2 Admin URL changed from
/admin/ to a less-discoverable path (defense in depth).
- DJG-ADM-3 Admin behind VPN / IP allowlist for production sites with regular user traffic.
- DJG-ADM-4 Custom admin actions reviewed for unintended privilege.
Static and media files
- DJG-STA-1
DEBUG = False means Django doesn't serve static files; deployed via nginx / S3 / CloudFront — confirm headers set there.
- DJG-STA-2
collectstatic output doesn't include accidentally-committed sensitive files.
Logging
- DJG-LOG-1 Logging config doesn't include
'level': 'DEBUG' for production. Don't log request bodies containing PII.
- DJG-LOG-2 Django's default email-error-to-admins (when DEBUG=False) sends tracebacks — verify recipients are correct, no broad mailing lists.
Dependencies
- DJG-DEP-1 Django version is on a current Long Term Support (LTS) line (4.2 LTS, 5.2 LTS) or current main. Old versions unpatched.
- DJG-DEP-2
pip list --outdated reviewed. safety check or pip-audit run periodically.
Phase 4: Triage
Critical: DEBUG = True in production; ALLOWED_HOSTS = ['*']; raw query with string concatenation; admin publicly accessible with weak password policy.
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with DJG-.
1---2name: django-security3description: Security audit for Django applications including settings.py (SECRET_KEY, DEBUG, ALLOWED_HOSTS), middleware order, ORM raw queries, template autoescape bypass, CSRF protection, Django Admin exposure, authentication backends, file upload handling, and Django-specific patterns. Use this skill whenever the user mentions Django, settings.py, manage.py, Django ORM, Django REST Framework, DRF, makemigrations, urls.py, views.py, or asks "audit my Django app", "Django security review", "Django settings safe". Trigger when the codebase contains `django` in `requirements.txt` / `pyproject.toml`, or `manage.py`, `settings.py`, `urls.py` files.4---56# Django Security Audit78Audit Django applications including DRF (Django REST Framework) APIs.910## When this skill applies1112- Reviewing Django `settings.py` files13- Auditing views, models, and templates14- Reviewing DRF serializers and viewsets15- Checking authentication and permission classes16- Auditing Django Admin exposure1718## Workflow1920Follow `../_shared/audit-workflow.md`.2122### Phase 1: Stack detection2324```bash25grep -E '^Django|django' requirements.txt pyproject.toml 2>/dev/null26find . -name 'manage.py' -not -path '*/.venv/*'27find . -name 'settings.py' -o -name 'settings/*.py' 2>/dev/null28python -c "import django; print(django.get_version())" 2>/dev/null29```3031### Phase 2: Inventory3233```bash34# Settings files35find . -name 'settings*.py' -not -path '*/.venv/*' -not -path '*/node_modules/*'3637# Views38find . -name 'views.py' -o -name 'views/' -type d 2>/dev/null3940# URL configurations41find . -name 'urls.py' 2>/dev/null4243# Raw SQL usage44grep -rn '\.raw(\|cursor()\|connection\.cursor' . --include='*.py' 2>/dev/null4546# Template autoescape disabling47grep -rn '|safe\|autoescape off\|mark_safe\|format_html' . --include='*.py' --include='*.html' 2>/dev/null48```4950### Phase 3: Detection — the checks5152#### `settings.py` audit5354- **DJG-SET-1** `SECRET_KEY` from environment variable, not committed:55 ```python56 SECRET_KEY = os.environ['DJANGO_SECRET_KEY']57 ```58 Audit: `git log -p settings.py | grep -i secret_key` — if any historical commit has a real secret, rotate.59- **DJG-SET-2** `DEBUG = False` in production. Confirmed via env-based settings split:60 ```python61 DEBUG = os.environ.get('DJANGO_DEBUG', '0') == '1'62 ```63- **DJG-SET-3** `ALLOWED_HOSTS` not `['*']` in production. Specific hostnames listed.64- **DJG-SET-4** `SECURE_*` settings configured:65 - `SECURE_SSL_REDIRECT = True`66 - `SESSION_COOKIE_SECURE = True`67 - `CSRF_COOKIE_SECURE = True`68 - `SECURE_HSTS_SECONDS = 31536000` (or more)69 - `SECURE_HSTS_INCLUDE_SUBDOMAINS = True`70 - `SECURE_HSTS_PRELOAD = True` (if you want preload list inclusion)71 - `SECURE_CONTENT_TYPE_NOSNIFF = True`72 - `SECURE_BROWSER_XSS_FILTER` — removed in Django 4 (XSS-Protection deprecated); use CSP instead73 - `X_FRAME_OPTIONS = 'DENY'` (or 'SAMEORIGIN')74- **DJG-SET-5** `CSRF_TRUSTED_ORIGINS` lists specific origins (not `*`).75- **DJG-SET-6** `DATABASES` config uses env vars for credentials.76- **DJG-SET-7** `EMAIL_*` config doesn't have plaintext credentials.77- **DJG-SET-8** `MIDDLEWARE` order:78 ```python79 MIDDLEWARE = [80 'django.middleware.security.SecurityMiddleware', # 1st81 'django.contrib.sessions.middleware.SessionMiddleware',82 'corsheaders.middleware.CorsMiddleware', # before CommonMiddleware83 'django.middleware.common.CommonMiddleware',84 'django.middleware.csrf.CsrfViewMiddleware',85 'django.contrib.auth.middleware.AuthenticationMiddleware',86 'django.contrib.messages.middleware.MessageMiddleware',87 'django.middleware.clickjacking.XFrameOptionsMiddleware',88 ]89 ```9091#### CSRF9293- **DJG-CSRF-1** `CsrfViewMiddleware` in MIDDLEWARE.94- **DJG-CSRF-2** No `@csrf_exempt` on state-changing views unless explicitly needed and documented (webhooks with their own signature verification are OK).95- **DJG-CSRF-3** DRF views using `SessionAuthentication` automatically apply CSRF. Token/JWT auth doesn't (and shouldn't, for non-cookie auth).9697#### ORM and raw SQL9899- **DJG-ORM-1** `.raw(sql, params)` uses parameterized queries (the `params` list). String concatenation is SQL injection.100 ```python101 # BAD102 User.objects.raw(f"SELECT * FROM auth_user WHERE id = {user_id}")103 104 # GOOD105 User.objects.raw("SELECT * FROM auth_user WHERE id = %s", [user_id])106 ```107- **DJG-ORM-2** `connection.cursor()` queries use parameterized queries.108- **DJG-ORM-3** `extra(where=[...])` with user input is injection-prone; prefer `Q()` filters.109- **DJG-ORM-4** `objects.filter(...)` with field lookups is safe; the ORM parameterizes.110111#### Mass assignment112113- **DJG-MA-1** `Model.objects.create(**request.POST)` patterns flagged. Use forms / serializers with explicit field allowlists.114- **DJG-MA-2** DRF `ModelSerializer` with `fields = '__all__'` is mass-assignable. Use explicit `fields = ['id', 'name', ...]`.115- **DJG-MA-3** Form `Meta.fields` and `Meta.exclude` reviewed.116117#### Templates118119- **DJG-TPL-1** Django templates auto-escape by default. `{{ var|safe }}` filter and `{% autoescape off %}` disable it — review every occurrence.120- **DJG-TPL-2** `mark_safe(...)` and `format_html(...)` in Python code mark strings as safe — confirm content is trusted.121- **DJG-TPL-3** SSTI: don't render user input as a template (`Template(user_input).render(...)`).122123#### Authentication124125- **DJG-AUTH-1** `AUTH_PASSWORD_VALIDATORS` configured with reasonable validators.126- **DJG-AUTH-2** `PASSWORD_HASHERS` first entry is Argon2 or BCrypt (Django default Argon2 is good).127- **DJG-AUTH-3** Custom backends (subclasses of `BaseBackend`) review `authenticate()` for timing attacks.128- **DJG-AUTH-4** `django.contrib.auth.password_validation.validate_password` called before set_password.129130#### DRF (Django REST Framework)131132- **DJG-DRF-1** Global `DEFAULT_PERMISSION_CLASSES` set to `IsAuthenticated` (or stricter); not `AllowAny`.133- **DJG-DRF-2** Per-view permission classes set; no view relying solely on URL routing for auth.134- **DJG-DRF-3** `ViewSet.queryset` filtered to tenant/user scope, not returning all objects.135- **DJG-DRF-4** Serializers define `fields` explicitly, exclude sensitive (`password`, `is_superuser`, `user_permissions` if not needed).136- **DJG-DRF-5** Throttling configured (`DEFAULT_THROTTLE_RATES`).137138```python139# REST_FRAMEWORK settings.py snippet140REST_FRAMEWORK = {141 'DEFAULT_AUTHENTICATION_CLASSES': [142 'rest_framework_simplejwt.authentication.JWTAuthentication',143 ],144 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated'],145 'DEFAULT_THROTTLE_CLASSES': [146 'rest_framework.throttling.UserRateThrottle',147 'rest_framework.throttling.AnonRateThrottle',148 ],149 'DEFAULT_THROTTLE_RATES': {150 'user': '1000/hour',151 'anon': '100/hour',152 },153}154```155156#### File uploads157158- **DJG-UP-1** `FILE_UPLOAD_MAX_MEMORY_SIZE` and `DATA_UPLOAD_MAX_MEMORY_SIZE` set.159- **DJG-UP-2** `FILE_UPLOAD_PERMISSIONS` restrictive (0o644).160- **DJG-UP-3** `MEDIA_ROOT` not within web-served path that could expose uploaded files without auth.161- **DJG-UP-4** Uploaded file validated by content (magic bytes), not just `content_type`.162163#### Django Admin164165- **DJG-ADM-1** Admin reachable only by staff users (`is_staff=True`).166- **DJG-ADM-2** Admin URL changed from `/admin/` to a less-discoverable path (defense in depth).167- **DJG-ADM-3** Admin behind VPN / IP allowlist for production sites with regular user traffic.168- **DJG-ADM-4** Custom admin actions reviewed for unintended privilege.169170#### Static and media files171172- **DJG-STA-1** `DEBUG = False` means Django doesn't serve static files; deployed via nginx / S3 / CloudFront — confirm headers set there.173- **DJG-STA-2** `collectstatic` output doesn't include accidentally-committed sensitive files.174175#### Logging176177- **DJG-LOG-1** Logging config doesn't include `'level': 'DEBUG'` for production. Don't log request bodies containing PII.178- **DJG-LOG-2** Django's default email-error-to-admins (when DEBUG=False) sends tracebacks — verify recipients are correct, no broad mailing lists.179180#### Dependencies181182- **DJG-DEP-1** Django version is on a current Long Term Support (LTS) line (4.2 LTS, 5.2 LTS) or current main. Old versions unpatched.183- **DJG-DEP-2** `pip list --outdated` reviewed. `safety check` or `pip-audit` run periodically.184185### Phase 4: Triage186187Critical: `DEBUG = True` in production; `ALLOWED_HOSTS = ['*']`; raw query with string concatenation; admin publicly accessible with weak password policy.188189### Phase 5: Report190191Use `../_shared/findings-schema.md`. Prefix IDs with `DJG-`.