Skip to content

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 = False in production
  • ALLOWED_HOSTS configured with specific hosts
  • SECRET_KEY loaded 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-audit in CI pipeline
  • bandit security 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

  1. Run security audit: Use python manage.py check --deploy and bandit
  2. Review OWASP Top 10: Ensure your app addresses each vulnerability
  3. Configure security headers: Enable HSTS, CSP, and other headers
  4. Implement rate limiting: Protect authentication and API endpoints
  5. Set up monitoring: Configure CloudWatch alarms for security events
  6. Create security tests: Write tests for security requirements
  7. Schedule dependency updates: Regular updates with vulnerability scanning
  8. Document security practices: Create an ADR for major security decisions