Django File Upload Security Checklist
Secure user uploads in Django production: size limits at every layer, content-based type checks, image re-encoding, safe file names, private storage, serving user content safely, scanning, and quotas.
Problem statement
File uploads let users put their content on your servers and, often, in front of other users. That makes them a common attack path: an "image" that is actually HTML with a script, an SVG that runs JavaScript when opened from your domain, a 10 GB upload that fills the disk, a decompression bomb that exhausts memory, a file name with path traversal characters, or a private document reachable by guessing its URL.
Django handles the mechanics of uploads safely, but policy is up to you. This checklist covers limits, validation, storage, serving, and monitoring for uploads in production.
- Limit size at the proxy (
client_max_body_size), in Django, and in presigned upload policies. - Validate file type by content (magic bytes or by opening with the right library), not by extension or the client's
Content-Type. - Re-encode images with Pillow and cap pixel counts; strip metadata you do not need.
- Generate storage names server-side; never use the uploaded name as a path.
- Store private files outside public URLs and serve them through permission checks or short-lived signed URLs.
- Serve user content with correct
Content-Type,nosniff, andContent-Disposition: attachmentfor risky types, ideally from a separate domain. - Scan documents for malware where users share files with each other, and enforce quotas and rate limits.
Checklist
1. Size limits at every layer
- Nginx
client_max_body_sizeis set per location to the largest legitimate upload (smaller for non-upload endpoints). - Django's
DATA_UPLOAD_MAX_MEMORY_SIZElimits non-file request data;FILE_UPLOAD_MAX_MEMORY_SIZEonly decides when uploads are streamed to disk instead of memory, so enforce file size limits in form or view validation. - Presigned direct-to-storage uploads use a
content-length-rangecondition. FILE_UPLOAD_TEMP_DIR(or the system temp directory) is on a disk with enough space and is cleaned up.
from django.core.exceptions import ValidationError
MAX_UPLOAD = 10 * 1024 * 1024
def validate_size(f):
if f.size > MAX_UPLOAD:
raise ValidationError("File too large (maximum 10 MB).")
2. Validate content, not labels
- Allowed types are an explicit allowlist per upload field.
- The client's
Content-Typeand the file extension are treated as hints only. - Content is checked: open images with Pillow, parse PDFs with a PDF library, or check magic bytes (for example with
python-magicorfiletype). FileExtensionValidatoris used in addition (it prevents storing.htmlunder an image field), never alone.
from PIL import Image, UnidentifiedImageError
def validate_image(f):
try:
with Image.open(f) as img:
img.verify()
if img.format not in {"JPEG", "PNG", "WEBP"}:
raise ValidationError("Unsupported image format.")
except (UnidentifiedImageError, OSError):
raise ValidationError("Not a valid image.")
finally:
f.seek(0)
3. Images
- Images are re-encoded (open, optionally resize, save to a new file) so embedded payloads and malformed structures are not stored as uploaded.
Image.MAX_IMAGE_PIXELSstays at a sane value (Pillow warns and then errors on decompression bombs); lower it if your use case allows.- EXIF metadata (including GPS location) is stripped from images that other users can see.
- SVG uploads are either not allowed or sanitised and served only as downloads; SVG can contain scripts.
4. File names and paths
- Storage names are generated server-side (for example a UUID plus a validated extension) using
upload_tocallables. - The original file name, if kept for display, is stored in a database field and escaped in templates, never used as a filesystem path.
- Storage paths include a per-owner or per-object prefix to make enumeration harder, but authorisation never relies on unguessable names alone.
import uuid
from pathlib import Path
def upload_to(instance, filename):
ext = Path(filename).suffix.lower()
return f"uploads/{instance.owner_id}/{uuid.uuid4().hex}{ext}"
5. Storage and access
- Public and private uploads use different storage locations (different directory or bucket).
- Private files are served only after a permission check:
X-Accel-Redirectto an internal Nginx location, or a short-lived presigned URL. - Object storage buckets for private files block public access; presigned URLs expire in minutes.
- Upload directories are never served in a way that could execute files (no script handlers on media locations).
6. Serving user content safely
X-Content-Type-Options: nosniffis sent (Django'sSecurityMiddlewaredoes this for its responses; configure Nginx or storage for directly served media).- Risky types (HTML, SVG, XML, unknown) are served with
Content-Disposition: attachmentand a safe content type (application/octet-stream). - User uploads are served from a separate domain (for example
usercontent.example.comor the bucket domain), so a malicious file cannot read cookies or call APIs on your main domain. - A Content Security Policy on your main site limits the impact of any content that does get rendered.
7. Malware and abuse
- Documents shared between users (attachments, marketplace files) are scanned (for example with ClamAV in a background task) before being made available.
- Files stay in a quarantine state until checks finish.
- Per-user quotas and upload rate limits prevent storage abuse.
- Archive uploads (ZIP) are not extracted automatically, or are extracted with limits on total size, file count, and path names.
8. Operations
- Upload endpoints are logged with user, size, and result (not file contents).
- Storage usage is monitored with alerts for unusual growth.
- Media backups exist and are tested; deletions honour retention and privacy obligations.
Explanation
An upload crosses several trust boundaries: from the client to your proxy, into Django, into storage, and finally out to other users' browsers. Each boundary has its own risks: resource exhaustion on the way in, type confusion and path manipulation in processing, unauthorised access in storage, and script execution on the way out. Limits, content validation, server-side naming, private storage, and careful serving address them one boundary at a time, so a single mistake does not become a vulnerability.
Edge cases / notes
Direct uploads to object storage
With presigned uploads, Django never sees the bytes during upload. Validate after upload (a background task that downloads and checks the object) before marking it usable, and keep unvalidated objects in a separate prefix.
Admin uploads
Staff uploads through the admin need the same validation; compromised staff accounts are a real threat, and admin-uploaded HTML served from your domain is as dangerous as a user's.
When to automate this
Put validators and upload_to functions in a shared module used by every file field, add tests with malicious samples (HTML disguised as PNG, oversized images, path traversal names), and run scanning and quarantine as a standard background workflow.
Next steps
- Serve private files safely with Serve Private Django Media Files with Nginx X-Accel-Redirect.
- Handle large files with Upload Files Directly to S3 from Django with Presigned URLs.
- Configure proxy limits with Fix Nginx 413 Request Entity Too Large for Django Uploads.
- Separate media from static files with Django Static vs Media Files in Production.
FAQ
How do I check the real type of an uploaded file in Django?
Inspect its content: open images with Pillow, parse documents with their libraries, or check magic bytes. Do not trust the extension or the client's Content-Type.
Why serve user uploads from a different domain?
A malicious file rendered on your main domain could run scripts with access to your users' cookies and your APIs. A separate domain isolates it.
Are SVG uploads dangerous?
Yes. SVG can contain JavaScript. Disallow SVG uploads, sanitise them, or serve them only as downloads from a separate domain.
Does FILE_UPLOAD_MAX_MEMORY_SIZE limit upload size?
No. It controls when uploads are streamed to disk. Enforce size limits at the proxy and in validation.