Security Best Practices¶
Security is not a feature to be added later; it's a fundamental aspect of application design and development. This guide covers security best practices for Django applications, focusing on practical, actionable guidance for small development teams building production-grade SaaS applications.
Security is a Journey
Security is not a checklist you complete once. It's an ongoing process of threat modeling, defense in depth, regular updates, and security-conscious development practices.
Security Mindset¶
Defense in Depth¶
Security should be layered: if one control fails, others still protect the system.
graph TB
A[External Attack] --> B[Network Layer: WAF, DDoS Protection]
B --> C[Transport Layer: TLS/HTTPS]
C --> D[Application Layer: Authentication]
D --> E[Authorization Layer: Permissions]
E --> F[Data Layer: Encryption at Rest]
F --> G[Audit Layer: Logging & Monitoring]
style A fill:#ffebee
style B fill:#fff9e1
style C fill:#fff9e1
style D fill:#e8f5e9
style E fill:#e8f5e9
style F fill:#e1f5ff
style G fill:#e1f5ff
Principle of Least Privilege¶
Grant minimum permissions necessary for functionality:
# ❌ Overly permissive
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all() # All users accessible
permission_classes = [IsAuthenticated] # Any authenticated user
# ✅ Restrictive with clear boundaries
class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, UserAccessPolicy]
def get_queryset(self):
# Users can only see themselves
return User.objects.filter(id=self.request.user.id)
Fail Securely¶
When errors occur, fail in a secure state:
def check_permission(user, resource):
"""Check if user has permission to access resource."""
try:
# Complex permission logic
return user.has_permission(resource)
except Exception:
# If anything goes wrong, deny access
logger.error("Permission check failed", exc_info=True)
return False # Fail securely: deny by default
OWASP Top 10 for Django¶
A01: Broken Access Control¶
Threat: Users accessing resources they shouldn't have access to.
Django Protection:
# Always filter querysets by ownership/tenant
class DocumentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, DocumentAccessPolicy]
def get_queryset(self):
"""Users can only access their own documents."""
return Document.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
"""Enforce ownership on creation."""
serializer.save(owner=self.request.user)
Multi-tenant access control:
from rest_access_policy import AccessPolicy
class DocumentAccessPolicy(AccessPolicy):
statements = [
{
"action": ["list", "retrieve"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_owner_or_tenant_member"
},
{
"action": ["create", "update", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_owner"
}
]
def is_owner(self, request, view, action):
"""Check if user owns the resource."""
obj = view.get_object()
return obj.owner == request.user
def is_owner_or_tenant_member(self, request, view, action):
"""Check if user is owner or same tenant."""
obj = view.get_object()
return (
obj.owner == request.user or
obj.tenant_id == request.user.tenant_id
)
A02: Cryptographic Failures¶
Threat: Exposure of sensitive data due to weak encryption or lack thereof.
Django Protection:
# settings/production.py - Enforce HTTPS everywhere
SECURE_SSL_REDIRECT = True # Redirect HTTP to HTTPS
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Secure cookies
SESSION_COOKIE_SECURE = True # Only send over HTTPS
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True # No JavaScript access
CSRF_COOKIE_HTTPONLY = True
# Secure headers
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY" # Prevent clickjacking
Database encryption:
# Use encrypted fields for sensitive data
from django_cryptography.fields import encrypt
class User(models.Model):
email = models.EmailField()
ssn = encrypt(models.CharField(max_length=11)) # Encrypted at rest
api_key = encrypt(models.CharField(max_length=64))
def set_password(self, raw_password):
"""Hash passwords with strong algorithm."""
self.password = make_password(raw_password) # Django uses PBKDF2
# settings.py - Password hashing configuration
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher", # Strongest
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]
Secrets management:
from app.commons.config.ps_config import get_parameter
# ❌ Never hardcode secrets
API_KEY = "sk_live_abc123..." # Wrong!
# ✅ Use AWS SSM Parameter Store
API_KEY = get_parameter("/app/stripe/api-key")
DATABASE_PASSWORD = get_parameter("/app/database/password")
# ✅ Never commit .env files
# .gitignore
.env
.env.local
secrets.json
A03: Injection¶
Threat: SQL injection, command injection, template injection.
Django Protection - SQL Injection:
# ✅ Django ORM prevents SQL injection
User.objects.filter(email=user_input) # Safe: parameterized query
# ❌ Never use raw SQL with string formatting
User.objects.raw(f"SELECT * FROM users WHERE email = '{user_input}'") # DANGEROUS!
# ✅ If you must use raw SQL, use parameterization
User.objects.raw(
"SELECT * FROM users WHERE email = %s",
[user_input] # Safely parameterized
)
Command Injection:
import subprocess
import shlex
# ❌ Never pass user input directly to shell
subprocess.call(f"convert {user_filename} output.pdf", shell=True) # DANGEROUS!
# ✅ Use list arguments without shell
subprocess.call(["convert", user_filename, "output.pdf"], shell=False)
# ✅ If you need shell features, validate and escape
escaped_filename = shlex.quote(user_filename)
subprocess.call(f"convert {escaped_filename} output.pdf", shell=True)
Template Injection:
# ✅ Django templates auto-escape by default
{{ user_input }} # Automatically HTML-escaped
# ❌ Don't mark user input as safe
{{ user_input|safe }} # Dangerous if user_input contains HTML/JS
# ✅ Only mark safe content from trusted sources
{{ admin_message|safe }} # OK if admin_message is from staff, not users
# ✅ Use the appropriate filter
{{ user_input|escape }} # Explicit escaping
{{ user_input|escapejs }} # For JavaScript context
A04: Insecure Design¶
Threat: Fundamental design flaws that can't be fixed with implementation changes.
Security by Design:
# Design pattern: Rate limiting at the design level
from django_ratelimit.decorators import ratelimit
@ratelimit(key='ip', rate='5/m', method='POST')
def login_view(request):
"""Login with built-in rate limiting."""
# 5 attempts per minute per IP
pass
# Design pattern: Secure password reset flow
class PasswordResetView(View):
"""
Secure password reset:
1. Request with email (no confirmation if email exists)
2. Time-limited token
3. One-time use token
4. Sent over email (second factor)
"""
def post(self, request):
email = request.POST.get('email')
# Always return success (timing-safe)
# Don't reveal if email exists
try:
user = User.objects.get(email=email)
token = self.generate_token(user)
self.send_reset_email(user, token)
except User.DoesNotExist:
pass # Same behavior: send nothing
return JsonResponse({"message": "If the email exists, reset link sent"})
def generate_token(self, user):
"""Generate cryptographically secure, time-limited token."""
from django.contrib.auth.tokens import default_token_generator
return default_token_generator.make_token(user)
Design for multi-tenancy:
class TenantIsolationMiddleware:
"""Ensure tenant isolation at the design level."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Set tenant from authenticated user
if request.user.is_authenticated:
request.tenant = request.user.tenant
else:
request.tenant = None
response = self.get_response(request)
return response
class Document(models.Model):
"""Document with tenant isolation built into the model."""
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
# Database-level constraint: users can only be in their tenant
constraints = [
models.CheckConstraint(
check=models.Q(owner__tenant=models.F('tenant')),
name='owner_tenant_match'
)
]
A05: Security Misconfiguration¶
Threat: Insecure default configurations, unnecessary features enabled, verbose error messages.
Django Configuration Hardening:
# settings/production.py - Secure production configuration
DEBUG = False # Never True in production
ALLOWED_HOSTS = [
"yourdomain.com",
"www.yourdomain.com",
] # Specific hosts only
# Disable debug features
DEBUG_PROPAGATE_EXCEPTIONS = False
# Remove debug toolbar in production
INSTALLED_APPS = [
# No debug_toolbar
]
# Custom error pages (don't reveal stack traces)
TEMPLATES = [{
'OPTIONS': {
'debug': False, # No template debug
}
}]
# Security headers
SECURE_REFERRER_POLICY = "same-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
# Content Security Policy
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'") # Minimize unsafe-inline
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
CSP_FONT_SRC = ("'self'", "data:")
CSP_CONNECT_SRC = ("'self'",)
CSP_FRAME_ANCESTORS = ("'none'",) # Prevent framing
Remove unnecessary features:
# Disable unused Django features
ADMINS = [] # Don't send 500 errors to email
MANAGERS = []
# Minimize installed apps
INSTALLED_APPS = [
# Only include what you need
# Remove django.contrib.admin in API-only apps
]
# Disable unnecessary middleware
MIDDLEWARE = [
# Remove unused middleware
]
A06: Vulnerable and Outdated Components¶
Threat: Using libraries with known vulnerabilities.
Dependency Management:
# pyproject.toml - Pin versions
[project]
dependencies = [
"django>=5.2,<6.0",
"djangorestframework>=3.15,<4.0",
]
[project.optional-dependencies]
dev = [
"pip-audit>=2.7", # Scan for vulnerabilities
"bandit>=1.7", # Security linting
]
Automated scanning:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: pip install uv
- name: Audit dependencies
run: |
uv pip compile pyproject.toml | uv pip audit --strict
- name: Bandit security scan
run: |
uv run bandit -r . -f json -o bandit-report.json
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: security-reports
path: bandit-report.json
Regular updates:
# Check for outdated packages
uv pip list --outdated
# Update dependencies
uv lock --upgrade
# Test after updates
uv run pytest
# Review changelog for breaking changes
A07: Identification and Authentication Failures¶
Threat: Broken authentication, weak passwords, session management issues.
Django Authentication:
# settings.py - Strong password requirements
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 12}, # Minimum 12 characters
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Session security
SESSION_COOKIE_AGE = 3600 # 1 hour
SESSION_SAVE_EVERY_REQUEST = True # Refresh on activity
SESSION_COOKIE_SAMESITE = "Strict"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True # HTTPS only
# CSRF protection
CSRF_COOKIE_SAMESITE = "Strict"
CSRF_COOKIE_HTTPONLY = True
CSRF_USE_SESSIONS = True # Store CSRF token in session
Multi-factor authentication:
from django_otp.decorators import otp_required
@otp_required
def sensitive_view(request):
"""Require MFA for sensitive operations."""
pass
class SensitiveViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, RequiresMFA]
Failed login tracking:
from django.core.cache import cache
from django.http import HttpResponseForbidden
class RateLimitLoginView(LoginView):
"""Track failed logins and implement lockout."""
def post(self, request, *args, **kwargs):
username = request.POST.get('username')
cache_key = f"login_attempts:{username}"
# Check lockout
attempts = cache.get(cache_key, 0)
if attempts >= 5:
logger.warning(
"Account locked due to too many failed attempts",
extra={"username": username, "ip": request.META.get('REMOTE_ADDR')}
)
return HttpResponseForbidden("Account temporarily locked")
response = super().post(request, *args, **kwargs)
# Track failed attempts
if response.status_code != 302: # Not a redirect (failed login)
cache.set(cache_key, attempts + 1, timeout=900) # 15 min lockout
return response
A08: Software and Data Integrity Failures¶
Threat: Unsigned packages, insecure CI/CD, auto-updates without verification.
Supply Chain Security:
# .github/workflows/build.yml - Verify integrity
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for verification
- name: Verify commit signatures
run: |
git verify-commit HEAD
- name: Install dependencies from lock file
run: |
uv sync --frozen # Fail if lock file doesn't match
- name: Verify package hashes
run: |
uv pip install --require-hashes -r requirements.txt
Code signing:
# Verify data integrity with signatures
import hmac
import hashlib
def sign_data(data, secret):
"""Create HMAC signature for data."""
signature = hmac.new(
secret.encode(),
data.encode(),
hashlib.sha256
).hexdigest()
return signature
def verify_signature(data, signature, secret):
"""Verify HMAC signature."""
expected = sign_data(data, secret)
return hmac.compare_digest(signature, expected) # Timing-safe comparison
A09: Security Logging and Monitoring Failures¶
Threat: Insufficient logging, not monitoring for security events.
Security Event Logging:
# Log security-relevant events
audit_logger = logging.getLogger("security.audit")
# Authentication events
audit_logger.info(
"Login successful",
extra={
"event_type": "authentication",
"user_id": user.id,
"ip_address": request.META.get('REMOTE_ADDR'),
"user_agent": request.META.get('HTTP_USER_AGENT')
}
)
# Authorization failures
audit_logger.warning(
"Unauthorized access attempt",
extra={
"event_type": "authorization_failure",
"user_id": request.user.id,
"resource": resource_id,
"action": action
}
)
# Data access
audit_logger.info(
"Sensitive data accessed",
extra={
"event_type": "data_access",
"user_id": request.user.id,
"resource_type": "patient_record",
"resource_id": record.id
}
)
CloudWatch Alarms:
{
"MetricFilters": [
{
"FilterName": "FailedLogins",
"FilterPattern": "{ $.event_type = \"authentication\" && $.status = \"failed\" }",
"MetricTransformations": [{
"MetricName": "FailedLoginAttempts",
"MetricValue": "1"
}]
},
{
"FilterName": "UnauthorizedAccess",
"FilterPattern": "{ $.event_type = \"authorization_failure\" }",
"MetricTransformations": [{
"MetricName": "UnauthorizedAccessAttempts",
"MetricValue": "1"
}]
}
],
"Alarms": [
{
"AlarmName": "HighFailedLogins",
"Threshold": 10,
"Period": 300,
"EvaluationPeriods": 1
}
]
}
A10: Server-Side Request Forgery (SSRF)¶
Threat: Application makes requests to unintended resources.
SSRF Protection:
import ipaddress
from urllib.parse import urlparse
def is_safe_url(url):
"""Validate URL to prevent SSRF."""
parsed = urlparse(url)
# Only allow HTTPS
if parsed.scheme != "https":
return False
# Resolve hostname to IP
try:
ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
except (socket.gaierror, ValueError):
return False
# Block private IP ranges
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
# Whitelist allowed domains
allowed_domains = ["api.stripe.com", "api.github.com"]
if parsed.hostname not in allowed_domains:
return False
return True
def fetch_external_resource(url):
"""Safely fetch external resource."""
if not is_safe_url(url):
raise ValueError("Invalid or unsafe URL")
response = requests.get(url, timeout=5)
return response
Django Security Middleware¶
SecurityMiddleware¶
Django's built-in security features:
# settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", # Must be first
# ... other middleware
]
# Security settings
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CORS Configuration¶
Control cross-origin requests:
# settings.py
INSTALLED_APPS = [
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
]
# Production: Specific origins only
CORS_ALLOWED_ORIGINS = [
"https://yourdomain.com",
"https://app.yourdomain.com",
]
# Allow credentials (cookies)
CORS_ALLOW_CREDENTIALS = True
# Preflight cache
CORS_PREFLIGHT_MAX_AGE = 86400
# Custom headers
CORS_ALLOW_HEADERS = list(default_headers) + [
"x-tenant-id",
"x-request-id",
]
Content Security Policy¶
Prevent XSS with CSP headers:
# settings.py
INSTALLED_APPS = [
"csp",
]
MIDDLEWARE = [
"csp.middleware.CSPMiddleware",
]
# Content Security Policy
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = (
"'self'",
"https://cdn.jsdelivr.net", # Only specific CDNs
)
CSP_STYLE_SRC = (
"'self'",
"'unsafe-inline'", # Required for inline styles
)
CSP_IMG_SRC = (
"'self'",
"data:",
"https:",
)
CSP_FONT_SRC = ("'self'", "data:")
CSP_CONNECT_SRC = ("'self'",)
CSP_FRAME_SRC = ("'none'",)
CSP_FRAME_ANCESTORS = ("'none'",) # Prevent clickjacking
CSP_REPORT_URI = "/csp-report/" # Report violations
Rate Limiting¶
Prevent abuse with rate limiting:
from django_ratelimit.decorators import ratelimit
from django_ratelimit.core import get_usage
@ratelimit(key='ip', rate='100/h', method='GET')
def api_endpoint(request):
"""Rate limit by IP address."""
pass
@ratelimit(key='user', rate='1000/d', method=['GET', 'POST'])
def user_endpoint(request):
"""Rate limit by authenticated user."""
pass
@ratelimit(key='header:x-api-key', rate='10000/h')
def api_key_endpoint(request):
"""Rate limit by API key."""
pass
# Check rate limit status
class RateLimitedViewSet(viewsets.ModelViewSet):
def list(self, request):
usage = get_usage(request, key='user', rate='1000/d')
if usage['should_limit']:
return Response(
{
"error": "Rate limit exceeded",
"limit": usage['limit'],
"remaining": usage['limit'] - usage['count']
},
status=429
)
return super().list(request)
Input Validation and Sanitization¶
Django Forms and Serializers¶
Use Django's built-in validation:
from django import forms
from rest_framework import serializers
# Form validation
class UserRegistrationForm(forms.Form):
email = forms.EmailField(max_length=255) # Validates email format
password = forms.CharField(
min_length=12,
max_length=128,
widget=forms.PasswordInput
)
age = forms.IntegerField(min_value=0, max_value=150)
def clean_email(self):
"""Custom email validation."""
email = self.cleaned_data['email']
if User.objects.filter(email=email).exists():
raise forms.ValidationError("Email already registered")
return email.lower()
# Serializer validation
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=12)
class Meta:
model = User
fields = ['email', 'password', 'first_name', 'last_name']
def validate_email(self, value):
"""Validate and normalize email."""
return value.lower().strip()
def validate(self, attrs):
"""Cross-field validation."""
if attrs['first_name'].lower() in attrs['password'].lower():
raise serializers.ValidationError(
"Password cannot contain your name"
)
return attrs
File Upload Security¶
Validate uploaded files:
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
import magic
class DocumentUploadSerializer(serializers.Serializer):
file = serializers.FileField(
validators=[
FileExtensionValidator(
allowed_extensions=['pdf', 'docx', 'txt']
)
]
)
def validate_file(self, file):
"""Validate file type and size."""
# Check file size (max 10MB)
if file.size > 10 * 1024 * 1024:
raise ValidationError("File size cannot exceed 10MB")
# Verify actual file type (not just extension)
file_type = magic.from_buffer(file.read(1024), mime=True)
file.seek(0) # Reset file pointer
allowed_types = ['application/pdf', 'application/msword', 'text/plain']
if file_type not in allowed_types:
raise ValidationError(f"Invalid file type: {file_type}")
return file
# Secure file storage
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_S3_FILE_OVERWRITE = False # Don't overwrite files
AWS_S3_OBJECT_PARAMETERS = {
"CacheControl": "max-age=86400",
}
AWS_DEFAULT_ACL = "private" # Never public by default
Security Testing¶
Django Security Checks¶
Use Django's built-in security checks:
# Run security checks
python manage.py check --deploy
# Example output:
# System check identified some issues:
# WARNINGS:
# ?: (security.W004) You have not set a value for SECURE_HSTS_SECONDS.
# ?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True.
Bandit Security Linting¶
Scan code for security issues:
# Install Bandit
uv add --dev bandit
# Scan entire project
bandit -r . -f json -o bandit-report.json
# Scan specific file
bandit app/views/api/users.py
# Example issues Bandit finds:
# - Use of assert
# - Hardcoded passwords
# - SQL injection vulnerabilities
# - Weak cryptography
pytest Security Tests¶
Test security requirements:
import pytest
from django.test import Client
class TestSecurityHeaders:
"""Test security headers are set correctly."""
def test_hsts_header(self):
"""Test HSTS header is set."""
client = Client()
response = client.get("/", secure=True)
assert "Strict-Transport-Security" in response.headers
assert "max-age=31536000" in response.headers["Strict-Transport-Security"]
def test_csp_header(self):
"""Test Content-Security-Policy is set."""
client = Client()
response = client.get("/")
assert "Content-Security-Policy" in response.headers
assert "default-src 'self'" in response.headers["Content-Security-Policy"]
class TestAuthentication:
"""Test authentication security."""
def test_password_requirements(self):
"""Test password complexity requirements."""
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
# Weak passwords should fail
with pytest.raises(ValidationError):
validate_password("password")
with pytest.raises(ValidationError):
validate_password("12345678")
# Strong passwords should pass
validate_password("MyS3cur3P@ssw0rd!")
def test_rate_limiting(self):
"""Test login rate limiting."""
client = Client()
# Make 6 failed login attempts
for i in range(6):
response = client.post("/login/", {
"username": "test",
"password": "wrong"
})
# 6th attempt should be rate limited
assert response.status_code == 429
Security Checklist¶
Use this checklist for security reviews:
Configuration¶
-
DEBUG = Falsein production -
ALLOWED_HOSTSconfigured with specific hosts -
SECRET_KEYloaded from environment, not hardcoded - HTTPS enforced (
SECURE_SSL_REDIRECT = True) - HSTS enabled (
SECURE_HSTS_SECONDS = 31536000) - Secure cookies (
SESSION_COOKIE_SECURE = True) -
X_FRAME_OPTIONS = "DENY" - Content Security Policy configured
- CORS configured with specific origins, not
CORS_ORIGIN_ALLOW_ALL
Authentication & Authorization¶
- Strong password requirements (12+ characters, complexity)
- Rate limiting on authentication endpoints
- Multi-factor authentication for sensitive operations
- Session timeout configured (
SESSION_COOKIE_AGE) - Password reset flow is secure (time-limited, one-time tokens)
- Permission checks on all views
- Object-level permissions enforced
- Failed login attempts logged
Data Protection¶
- Sensitive data encrypted at rest
- TLS 1.3 for data in transit
- PII identified and protected
- Database credentials stored in SSM, not code
- API keys stored in SSM, not code
- Backups encrypted
- File uploads validated (type, size)
- File storage is private by default
Dependencies¶
- Dependencies pinned in
uv.lock - Regular dependency updates scheduled
-
pip-auditin CI pipeline -
banditsecurity scanning in CI - No known vulnerabilities in dependencies
- Unused dependencies removed
Input Validation¶
- All user input validated
- Django forms/serializers used for validation
- SQL injection prevented (use ORM)
- XSS prevented (template auto-escaping)
- CSRF protection enabled
- File uploads validated
- URL redirects validated (no open redirects)
Logging & Monitoring¶
- Security events logged
- Failed authentication logged
- Authorization failures logged
- CloudWatch alarms configured
- Sentry error tracking configured
- Logs don't contain secrets or PII
- Audit trail for sensitive operations
Infrastructure¶
- Database not publicly accessible
- Application behind WAF
- DDoS protection enabled
- Security groups follow least privilege
- IAM roles follow least privilege
- No SSH access to production (use ECS Exec)
- Secrets rotation configured
- Intrusion detection enabled
Next Steps¶
- Run security audit: Use
python manage.py check --deployandbandit - Review OWASP Top 10: Ensure your app addresses each vulnerability
- Configure security headers: Enable HSTS, CSP, and other headers
- Implement rate limiting: Protect authentication and API endpoints
- Set up monitoring: Configure CloudWatch alarms for security events
- Create security tests: Write tests for security requirements
- Schedule dependency updates: Regular updates with vulnerability scanning
- Document security practices: Create an ADR for major security decisions
Further Reading
- OWASP Top 10
- Django Security
- Django Deployment Checklist
- AWS Security Best Practices
- Logging Best Practices - Security event logging
- 12-Factor Config - Secrets management