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.

Upload defence layersProxy limitssize, rateValidationcontent, not nameStoragerandom names, privateServingsafe type, own domainEach layer assumes the previous one can fail; an upload stays untrusted until served safely.
  1. Limit size at the proxy (client_max_body_size), in Django, and in presigned upload policies.
  2. Validate file type by content (magic bytes or by opening with the right library), not by extension or the client's Content-Type.
  3. Re-encode images with Pillow and cap pixel counts; strip metadata you do not need.
  4. Generate storage names server-side; never use the uploaded name as a path.
  5. Store private files outside public URLs and serve them through permission checks or short-lived signed URLs.
  6. Serve user content with correct Content-Type, nosniff, and Content-Disposition: attachment for risky types, ideally from a separate domain.
  7. 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_size is set per location to the largest legitimate upload (smaller for non-upload endpoints).
  • Django's DATA_UPLOAD_MAX_MEMORY_SIZE limits non-file request data; FILE_UPLOAD_MAX_MEMORY_SIZE only 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-range condition.
  • FILE_UPLOAD_TEMP_DIR (or the system temp directory) is on a disk with enough space and is cleaned up.
a size limit at every layerCDN or load balancerrequest body capNginx client_max_body_sizerejects with 413 before DjangoDjango DATA_UPLOAD_MAX_MEMORY_SIZEnon-file form dataForm or serializer validatorper-field file sizeThe outermost limit protects workers; the innermost gives a friendly errordjango-deployment.com
FILE_UPLOAD_MAX_MEMORY_SIZE only decides memory versus temp file, not what is allowed.
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-Type and 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-magic or filetype).
  • FileExtensionValidator is used in addition (it prevents storing .html under an image field), never alone.
the client controls the labelsTrusting labels✕file extension✕Content-Type header✕original file nameChecking content✓magic bytes match an allowlist✓images decoded and re-encoded✓stored under a generated nameAn .jpg that is really HTML becomes stored XSS if served inlinedjango-deployment.com
Re-encoding images also strips metadata such as GPS coordinates.
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_PIXELS stays 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_to callables.
  • 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-Redirect to 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: nosniff is sent (Django's SecurityMiddleware does this for its responses; configure Nginx or storage for directly served media).
  • Risky types (HTML, SVG, XML, unknown) are served with Content-Disposition: attachment and a safe content type (application/octet-stream).
  • User uploads are served from a separate domain (for example usercontent.example.com or 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.
serving uploads without running themSettingStopsSeparate domainusercontent.example.netscripts reading your cookiesContent-Dispositionattachment for non-imagesHTML and SVG running inlineX-Content-Type-Optionsnosniffbrowsers guessing a script typePrivate filessigned, expiring URLslinks shared beyond the ownerSVG uploads are images that can carry scriptsdjango-deployment.com
Either reject SVG uploads or always serve them as attachments.

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

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.

Keep going