Django's approach to security is built on the principle of "secure by default" - providing robust security features out of the box while making it easy for developers to build secure applications. This chapter explores Django's security philosophy and core principles.
Django enables security features by default, requiring developers to explicitly opt-out rather than opt-in:
# settings.py - Django's secure defaults
# CSRF protection is enabled by default
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', # Enabled by default
# ... other middleware
]
# Template auto-escaping is enabled by default
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'OPTIONS': {
'autoescape': True, # Default behavior
},
},
]
# Secure session cookies
SESSION_COOKIE_SECURE = True # Should be True in production
SESSION_COOKIE_HTTPONLY = True # Default is True
SESSION_COOKIE_SAMESITE = 'Lax' # Default protection
# Password validation is enabled by default
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 8,
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
Django's architecture prioritizes security considerations:
# Example: ORM prevents SQL injection by default
from django.contrib.auth.models import User
# SAFE: Django ORM uses parameterized queries
def get_user_by_username(username):
# This is automatically protected against SQL injection
return User.objects.filter(username=username).first()
# UNSAFE: Raw SQL without proper escaping (avoid this)
def unsafe_get_user(username):
from django.db import connection
cursor = connection.cursor()
# DON'T DO THIS - vulnerable to SQL injection
cursor.execute(f"SELECT * FROM auth_user WHERE username = '{username}'")
return cursor.fetchone()
# SAFE: Raw SQL with proper parameterization
def safe_raw_sql(username):
from django.db import connection
cursor = connection.cursor()
cursor.execute("SELECT * FROM auth_user WHERE username = %s", [username])
return cursor.fetchone()
Django implements multiple layers of security protection:
# Layer 1: Input validation at the form level
from django import forms
from django.core.exceptions import ValidationError
class UserRegistrationForm(forms.Form):
username = forms.CharField(
max_length=150,
validators=[validate_username] # Custom validation
)
email = forms.EmailField() # Built-in email validation
password = forms.CharField(
widget=forms.PasswordInput(),
validators=[validate_password] # Password strength validation
)
def clean_username(self):
"""Additional username validation"""
username = self.cleaned_data['username']
# Check for prohibited characters
if any(char in username for char in ['<', '>', '"', "'"]):
raise ValidationError("Username contains invalid characters")
# Check for existing username
if User.objects.filter(username=username).exists():
raise ValidationError("Username already exists")
return username
# Layer 2: Model-level validation
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(
max_length=500,
validators=[validate_no_html] # Prevent HTML injection
)
website = models.URLField(
validators=[validate_safe_url] # Validate URL safety
)
def clean(self):
"""Model-level validation"""
super().clean()
# Additional business logic validation
if self.bio and len(self.bio.strip()) < 10:
raise ValidationError("Bio must be at least 10 characters long")
# Layer 3: View-level security
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_http_methods
@login_required
@csrf_protect
@require_http_methods(["GET", "POST"])
def update_profile(request):
"""Secure profile update view"""
# Additional authorization check
if not request.user.has_perm('accounts.change_userprofile'):
return HttpResponseForbidden("Permission denied")
if request.method == 'POST':
form = UserProfileForm(request.POST, instance=request.user.profile)
if form.is_valid():
# Additional security check before saving
if not is_safe_content(form.cleaned_data['bio']):
messages.error(request, "Content contains unsafe elements")
return render(request, 'profile_form.html', {'form': form})
form.save()
messages.success(request, "Profile updated successfully")
return redirect('profile_detail')
else:
form = UserProfileForm(instance=request.user.profile)
return render(request, 'profile_form.html', {'form': form})
Django templates provide automatic security features:
<!-- Django templates auto-escape by default -->
<div class="user-content">
<!-- This is automatically escaped - safe from XSS -->
{{ user.bio }}
<!-- Explicitly mark as safe only when you're certain -->
{{ trusted_html_content|safe }}
<!-- Use filters for additional security -->
{{ user_input|escape|linebreaks }}
</div>
<!-- Custom template filter for additional security -->
{% load security_filters %}
<div class="sanitized-content">
{{ user_content|sanitize_html }}
</div>
# templatetags/security_filters.py
from django import template
from django.utils.safestring import mark_safe
import bleach
register = template.Library()
@register.filter
def sanitize_html(value):
"""Sanitize HTML content to prevent XSS"""
if not value:
return ''
# Allow only safe HTML tags
allowed_tags = [
'p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'
]
allowed_attributes = {
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height'],
}
cleaned = bleach.clean(
value,
tags=allowed_tags,
attributes=allowed_attributes,
strip=True
)
return mark_safe(cleaned)
Django implements granular permission controls:
# models.py - Define custom permissions
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
published = models.BooleanField(default=False)
class Meta:
permissions = [
("can_publish_article", "Can publish articles"),
("can_feature_article", "Can feature articles"),
("can_moderate_comments", "Can moderate comments"),
]
# views.py - Implement permission checks
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
@permission_required('blog.can_publish_article')
def publish_article(request, article_id):
"""Only users with publish permission can access this view"""
article = get_object_or_404(Article, id=article_id)
# Additional ownership check
if article.author != request.user and not request.user.is_staff:
return HttpResponseForbidden("You can only publish your own articles")
article.published = True
article.save()
return redirect('article_detail', article_id=article.id)
class ArticleUpdateView(PermissionRequiredMixin, UpdateView):
"""Class-based view with permission requirements"""
model = Article
permission_required = 'blog.change_article'
def get_object(self, queryset=None):
"""Ensure users can only edit their own articles"""
obj = super().get_object(queryset)
if obj.author != self.request.user and not self.request.user.is_staff:
raise PermissionDenied("You can only edit your own articles")
return obj
# Custom permission decorator
def author_required(view_func):
"""Decorator to ensure user is the author of the article"""
def wrapper(request, article_id, *args, **kwargs):
article = get_object_or_404(Article, id=article_id)
if article.author != request.user and not request.user.is_staff:
return HttpResponseForbidden("Access denied")
return view_func(request, article_id, *args, **kwargs)
return wrapper
@login_required
@author_required
def edit_article(request, article_id):
"""Edit article with author verification"""
article = get_object_or_404(Article, id=article_id)
# ... view logic
Implement object-level permissions:
# Custom permission backend
from django.contrib.auth.backends import BaseBackend
class ObjectPermissionBackend(BaseBackend):
"""Custom backend for object-level permissions"""
def has_perm(self, user_obj, perm, obj=None):
"""Check object-level permissions"""
if not user_obj.is_active:
return False
if obj is None:
return False
# Check if user owns the object
if hasattr(obj, 'author') and obj.author == user_obj:
return True
# Check if user is in allowed groups
if hasattr(obj, 'allowed_groups'):
user_groups = user_obj.groups.all()
if obj.allowed_groups.filter(id__in=user_groups).exists():
return True
return False
# settings.py
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'myapp.backends.ObjectPermissionBackend',
]
# Usage in views
def article_detail(request, article_id):
article = get_object_or_404(Article, id=article_id)
# Check object-level permission
if not request.user.has_perm('blog.view_article', article):
return HttpResponseForbidden("You don't have permission to view this article")
return render(request, 'article_detail.html', {'article': article})
Django handles errors securely by default:
# settings.py - Production error handling
DEBUG = False # Never True in production
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
# Custom error pages that don't leak information
TEMPLATES = [
{
'DIRS': [os.path.join(BASE_DIR, 'templates')],
# ... other settings
},
]
# Secure logging configuration
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'ERROR',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/error.log',
'maxBytes': 10*1024*1024, # 10MB
'backupCount': 5,
'formatter': 'verbose',
},
'security': {
'level': 'WARNING',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/security.log',
'maxBytes': 10*1024*1024,
'backupCount': 5,
'formatter': 'verbose',
},
},
'loggers': {
'django.security': {
'handlers': ['security'],
'level': 'WARNING',
'propagate': False,
},
'django': {
'handlers': ['file'],
'level': 'ERROR',
'propagate': True,
},
},
}
# Custom error views
def custom_404_view(request, exception):
"""Custom 404 handler that doesn't leak information"""
return render(request, '404.html', status=404)
def custom_500_view(request):
"""Custom 500 handler for server errors"""
# Log the error for debugging
import logging
logger = logging.getLogger('django')
logger.error('Server error occurred', exc_info=True, extra={
'request': request,
})
return render(request, '500.html', status=500)
# urls.py
handler404 = 'myapp.views.custom_404_view'
handler500 = 'myapp.views.custom_500_view'
Implement secure fallbacks when security features fail:
# Secure session handling with fallbacks
class SecureSessionMiddleware:
"""Enhanced session middleware with security fallbacks"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Validate session security
if not self.is_session_secure(request):
# Clear potentially compromised session
request.session.flush()
# Log security event
logger.warning(f"Insecure session detected from {self.get_client_ip(request)}")
response = self.get_response(request)
# Ensure secure session cookies
if hasattr(request, 'session') and request.session.modified:
self.secure_session_cookie(response)
return response
def is_session_secure(self, request):
"""Validate session security"""
if not hasattr(request, 'session'):
return True
# Check for session hijacking indicators
stored_ip = request.session.get('_session_ip')
current_ip = self.get_client_ip(request)
if stored_ip and stored_ip != current_ip:
return False
# Check session age
session_start = request.session.get('_session_start')
if session_start:
from datetime import datetime, timedelta
if datetime.now() - datetime.fromisoformat(session_start) > timedelta(hours=24):
return False
return True
def secure_session_cookie(self, response):
"""Ensure session cookie security"""
if 'sessionid' in response.cookies:
response.cookies['sessionid']['secure'] = True
response.cookies['sessionid']['httponly'] = True
response.cookies['sessionid']['samesite'] = 'Strict'
Django provides comprehensive security documentation:
# Example: Documenting security considerations in code
class PaymentView(View):
"""
Handle payment processing with security considerations:
Security Features:
- CSRF protection via @csrf_protect decorator
- SSL/TLS required via @require_https decorator
- Rate limiting to prevent abuse
- Input validation and sanitization
- Audit logging for all transactions
Security Considerations:
- This view handles sensitive financial data
- All inputs are validated and sanitized
- Failed attempts are logged and monitored
- PCI DSS compliance requirements apply
"""
@method_decorator(csrf_protect)
@method_decorator(require_https)
@method_decorator(ratelimit(key='ip', rate='5/m', method='POST'))
def post(self, request):
# Security audit log
logger.info(f"Payment attempt from {self.get_client_ip(request)}", extra={
'user': request.user.id if request.user.is_authenticated else None,
'ip_address': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
})
# Validate and process payment
form = PaymentForm(request.POST)
if form.is_valid():
try:
# Process payment securely
result = self.process_payment(form.cleaned_data)
# Log successful transaction
logger.info(f"Payment successful: {result['transaction_id']}")
return JsonResponse({'status': 'success', 'transaction_id': result['transaction_id']})
except PaymentError as e:
# Log payment failure
logger.warning(f"Payment failed: {str(e)}")
return JsonResponse({'status': 'error', 'message': 'Payment processing failed'})
# Log validation errors
logger.warning(f"Payment form validation failed: {form.errors}")
return JsonResponse({'status': 'error', 'errors': form.errors})
Implement comprehensive security headers:
# middleware.py - Security headers middleware
class SecurityHeadersMiddleware:
"""Add comprehensive security headers"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Content Security Policy
csp_directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
]
response['Content-Security-Policy'] = '; '.join(csp_directives)
# Additional security headers
response['X-Content-Type-Options'] = 'nosniff'
response['X-Frame-Options'] = 'DENY'
response['X-XSS-Protection'] = '1; mode=block'
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
# HSTS (only over HTTPS)
if request.is_secure():
response['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload'
return response
Implement ongoing security monitoring:
# Security monitoring middleware
class SecurityMonitoringMiddleware:
"""Monitor and log security events"""
def __init__(self, get_response):
self.get_response = get_response
self.suspicious_patterns = [
r'<script[^>]*>', # Script injection attempts
r'union\s+select', # SQL injection attempts
r'\.\./', # Path traversal attempts
r'javascript:', # JavaScript injection
]
def __call__(self, request):
# Monitor for suspicious activity
self.check_suspicious_activity(request)
response = self.get_response(request)
# Log security-relevant events
self.log_security_events(request, response)
return response
def check_suspicious_activity(self, request):
"""Check for suspicious request patterns"""
import re
# Check query parameters
for key, value in request.GET.items():
for pattern in self.suspicious_patterns:
if re.search(pattern, value, re.IGNORECASE):
logger.warning(f"Suspicious GET parameter detected: {key}={value}", extra={
'ip_address': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
'path': request.path,
})
# Check POST data
if request.method == 'POST':
for key, value in request.POST.items():
for pattern in self.suspicious_patterns:
if re.search(pattern, str(value), re.IGNORECASE):
logger.warning(f"Suspicious POST parameter detected: {key}", extra={
'ip_address': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
'path': request.path,
})
def log_security_events(self, request, response):
"""Log security-relevant events"""
# Log failed authentication attempts
if response.status_code == 403:
logger.warning(f"Access denied: {request.path}", extra={
'ip_address': self.get_client_ip(request),
'user': getattr(request, 'user', None),
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
})
# Log admin access
if request.path.startswith('/admin/'):
logger.info(f"Admin access: {request.path}", extra={
'ip_address': self.get_client_ip(request),
'user': getattr(request, 'user', None),
'method': request.method,
})
Now that you understand Django's security philosophy, let's dive into specific security protections, starting with Cross-Site Request Forgery (CSRF) protection.
Security
Security is a fundamental aspect of web application development, and Django provides robust built-in protections against common web vulnerabilities. This comprehensive guide covers Django's security features and best practices for building secure applications.
Cross Site Request Forgery
Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they're currently authenticated. Django provides robust built-in CSRF protection that's enabled by default.