Upload Files Directly to S3 from Django with Presigned URLs

Let browsers upload large files straight to S3 with presigned POST policies: size and type limits, bucket CORS, server-side confirmation, FileField wiring, and cleanup of abandoned uploads.

Problem statement

When users upload videos, large PDFs, or datasets through a normal Django form, the file travels browser → proxy → Gunicorn → Django → S3. Every hop has a size limit and a timeout, a worker is occupied for the whole upload, and on platforms with request timeouts (Heroku, Cloud Run, many load balancers) large uploads simply fail.

Direct uploads remove Django from the data path. Django issues a short-lived, signed upload policy; the browser sends the file straight to S3; then Django confirms the upload and records it. This guide implements that flow with limits that S3 enforces, so the signed policy cannot be abused.

Direct browser upload to S3Browserasks for a policyDjangopresigned POSTS3 bucketenforces the limitsDjango confirmHEAD, then saveFile bytes never pass through Gunicorn; Django only signs the policy and verifies the result.
  1. Configure bucket CORS to allow POST from your site's origin.
  2. Add a Django view that authenticates the user, generates an object key server-side, and returns generate_presigned_post with a content-length-range condition, a fixed Content-Type, and a short expiry.
  3. In the browser, POST the returned fields plus the file (as the last field) to the returned URL.
  4. Call a confirmation endpoint with the key; Django checks the object with head_object (size, type) and saves the key on a FileField.
  5. Upload into a temporary prefix and expire abandoned objects with a lifecycle rule.

Step-by-step solution

1. Bucket CORS

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Location"],
    "MaxAgeSeconds": 3000
  }
]
aws s3api put-bucket-cors --bucket myapp-media --cors-configuration file://cors.json

Keep the bucket's public access block enabled. Presigned uploads do not require any public access.

2. IAM permissions

The credentials Django uses to sign the policy must be allowed to perform the upload themselves, because the signature grants their permissions:

{
  "Effect": "Allow",
  "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
  "Resource": "arn:aws:s3:::myapp-media/uploads/*"
}

Add s3:ListBucket on the bucket itself if you want head_object to return 404 rather than 403 for missing keys.

3. Issue the upload policy

# uploads/views.py
import uuid

import boto3
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST

s3 = boto3.client("s3", region_name=settings.AWS_S3_REGION_NAME)

ALLOWED_TYPES = {"application/pdf": ".pdf", "video/mp4": ".mp4"}
MAX_BYTES = 500 * 1024 * 1024


@login_required
@require_POST
def create_upload(request):
    content_type = request.POST.get("content_type", "")
    if content_type not in ALLOWED_TYPES:
        return JsonResponse({"error": "unsupported type"}, status=400)

    key = f"uploads/tmp/{request.user.pk}/{uuid.uuid4().hex}{ALLOWED_TYPES[content_type]}"
    post = s3.generate_presigned_post(
        Bucket=settings.AWS_STORAGE_BUCKET_NAME,
        Key=key,
        Fields={"Content-Type": content_type},
        Conditions=[
            {"Content-Type": content_type},
            ["content-length-range", 1, MAX_BYTES],
        ],
        ExpiresIn=300,
    )
    return JsonResponse({"key": key, "url": post["url"], "fields": post["fields"]})
the file never passes through DjangoBrowser asks Djangoname, type, sizeDjango signs a POSTpolicykey, limits, expiryBrowser uploads to S3directlyBrowser confirmssends the key backDjango verifies andattachesHEAD, then saveWorkers stay free while large files uploaddjango-deployment.com
The policy decides what S3 accepts, so set size and type limits there.
  • The key is generated by the server, so users cannot overwrite other objects or choose paths.
  • Conditions are enforced by S3. A request with a different content type or a size outside the range is rejected by S3, not by your code.
  • Five minutes is enough to start an upload; S3 checks the expiry when the request begins.
  • The view uses POST and Django's normal CSRF protection.

4. Upload from the browser

async function uploadFile(file) {
  const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
  const policyRes = await fetch("/uploads/create/", {
    method: "POST",
    headers: { "X-CSRFToken": csrf },
    body: new URLSearchParams({ content_type: file.type }),
  });
  if (!policyRes.ok) throw new Error("upload not allowed");
  const { key, url, fields } = await policyRes.json();

  const form = new FormData();
  Object.entries(fields).forEach(([k, v]) => form.append(k, v));
  form.append("file", file); // must be the last field

  const s3Res = await fetch(url, { method: "POST", body: form });
  if (!s3Res.ok) throw new Error(`S3 rejected upload: ${s3Res.status}`);

  const confirmRes = await fetch("/uploads/confirm/", {
    method: "POST",
    headers: { "X-CSRFToken": csrf },
    body: new URLSearchParams({ key }),
  });
  return confirmRes.json();
}

S3 ignores form fields after the file, so the file must come last. For progress bars, use XMLHttpRequest and its upload.onprogress event instead of fetch.

5. Confirm and attach the file

# uploads/views.py (continued)
from botocore.exceptions import ClientError

from documents.models import Document


@login_required
@require_POST
def confirm_upload(request):
    key = request.POST.get("key", "")
    if not key.startswith(f"uploads/tmp/{request.user.pk}/"):
        return JsonResponse({"error": "invalid key"}, status=400)
    try:
        head = s3.head_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
    except ClientError:
        return JsonResponse({"error": "upload not found"}, status=404)

    if head["ContentLength"] > MAX_BYTES or head["ContentType"] not in ALLOWED_TYPES:
        s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)
        return JsonResponse({"error": "invalid upload"}, status=400)

    final_key = key.replace("uploads/tmp/", "uploads/files/", 1)
    s3.copy_object(
        Bucket=settings.AWS_STORAGE_BUCKET_NAME,
        CopySource={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": key},
        Key=final_key,
    )
    s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=key)

    doc = Document(owner=request.user)
    doc.file.name = final_key      # FileField whose storage is the same bucket
    doc.save()
    return JsonResponse({"id": doc.pk})
never trust the confirmation blindlyKey under this user’s prefixnot someone else’s fileObject existsHEAD succeedsSize within limitsContentLengthType is allowedcheck content, not only the headerMove accepted files out of the pending prefixdjango-deployment.com
Treat the key the browser sends as untrusted input.

Setting file.name attaches the existing object without re-uploading it. copy_object handles objects up to 5 GB in one call; for larger files use boto3's managed copy().

The Content-Type in the object's metadata is what the client declared, constrained by the policy. It does not prove the file's actual content. If you need that guarantee, inspect the first bytes (a ranged get_object) or run a scanning step before marking the document usable.

6. Clean up abandoned uploads

Uploads that are never confirmed stay in uploads/tmp/. Expire them automatically:

{
  "Rules": [{
    "ID": "expire-tmp-uploads",
    "Filter": {"Prefix": "uploads/tmp/"},
    "Status": "Enabled",
    "Expiration": {"Days": 1},
    "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 1}
  }]
}
uploads that never get confirmedNo cleanup✕closed tabs leave files behind✕storage grows forever✕unreviewed files in the bucketLifecycle rule on pending/✓uploads land under pending/✓confirmed ones are moved✓the rest expire after a dayA prefix plus a lifecycle rule needs no cron jobdjango-deployment.com
Also abort incomplete multipart uploads with a lifecycle rule.
aws s3api put-bucket-lifecycle-configuration --bucket myapp-media --lifecycle-configuration file://lifecycle.json

Verification check:

  • An upload within limits succeeds and the document record points at uploads/files/....
  • An upload larger than MAX_BYTES is rejected by S3 with 400 (EntityTooLarge).
  • Changing the file's content type in the form causes S3 to reject it with 403 (policy condition failed).
  • Gunicorn's access log shows only the small policy and confirm requests.

Explanation

A presigned POST is a policy document (bucket, key, allowed fields, conditions, expiry) signed with your credentials. S3 validates the signature and every condition before accepting the upload, so the browser can talk to S3 directly while your server still decides what may be uploaded, where, and how large. The confirm step closes the loop: the database only references objects that exist and passed your checks.

Edge cases / notes

Presigned PUT versus POST

A presigned PUT URL is simpler but cannot enforce a size range. Use POST policies when size limits matter, which is almost always for user uploads.

Very large files

Above a few hundred megabytes, multipart uploads are more reliable: each part is presigned separately and can be retried. This needs more client code or a library.

Other S3-compatible storage

Cloudflare R2, DigitalOcean Spaces, and others support presigned URLs, but support for POST policies varies; R2, for example, supports presigned PUT but not POST policy uploads. Check your provider before choosing the flow.

When to automate this

The CORS rules, lifecycle rule, and IAM policy belong in infrastructure code. The policy and confirm views are worth wrapping in a small reusable app with tests for size, type, and key-prefix enforcement, since the same pattern tends to spread across features.

Next steps

FAQ

Why upload directly to S3 instead of through Django?

Large uploads through Django occupy workers and hit proxy size limits and platform timeouts. Direct uploads send the bytes to S3 while Django only signs and verifies.

How do I limit file size with presigned uploads?

Use a presigned POST with a content-length-range condition. S3 rejects files outside the range.

Why does S3 return a CORS error in the browser?

The bucket has no CORS rule allowing POST from your site's origin. Add one with put-bucket-cors.

How do I attach an uploaded S3 object to a Django FileField?

Set instance.file.name to the object key, with the field's storage pointing at the same bucket, and save the model.

Keep going