13 Aug 2026 Tutorial 6 min read

Direct Browser Uploads to S3 with Presigned URLs (Django + boto3)

Upload files from the browser straight to S3 — no proxying through your backend. Presigned PUT URLs with Django, the s3v4 SignatureDoesNotMatch fix, and the exact CORS config you need.

Most Django apps handle file uploads the slow way: the browser sends the file to a Django view, the view streams it to S3, and your worker sits blocked the whole time. For a 50 MB file on a slow connection, that’s a worker doing nothing but babysitting bytes — twice, once in and once out.

The better pattern: your backend generates a presigned URL — a short-lived, cryptographically signed permission slip — and the browser uploads directly to S3. Django’s only job is authorization. This is the same pattern I used in production for an event-driven document ingestion pipeline, and it’s what this tutorial builds, including the two things that break for almost everyone the first time: the signature version and CORS.

How presigned URLs work

A presigned URL embeds your credentials’ signature, the bucket, the object key, the HTTP method, and an expiry into query parameters. Anyone holding the URL can perform exactly that operation until it expires — no AWS account needed. Your backend decides who may upload what, where; S3 does the heavy lifting.

The flow:

  1. Browser asks your Django API: “I want to upload report.pdf.”
  2. Django validates the request (auth, file type, naming) and returns a presigned PUT URL, valid for a few minutes.
  3. Browser PUTs the file bytes directly to that URL.
  4. S3 stores the object. Your backend never touches the file.

Step 1 — Bucket and IAM setup

Create the bucket (I’m using ap-south-1 / Mumbai throughout):

aws s3api create-bucket \
  --bucket awspractice-s3-demo \
  --region ap-south-1 \
  --create-bucket-configuration LocationConstraint=ap-south-1

The credentials Django uses should be able to write only to the upload prefix — not administer the bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::awspractice-s3-demo/uploads/*"
    }
  ]
}

If these credentials leak, the blast radius is “someone can add files to one folder” — not “someone can delete your bucket.”

Step 2 — Generate the presigned URL in Django

This is where the most common failure hides. Here’s the working version:

# uploads/services.py
import uuid

import boto3
from botocore.client import Config
from django.conf import settings

def build_s3_client():
    return boto3.client(
        "s3",
        region_name="ap-south-1",
        endpoint_url="https://s3.ap-south-1.amazonaws.com",
        config=Config(signature_version="s3v4"),
    )

def generate_upload_url(filename: str, content_type: str) -> dict:
    key = f"uploads/{uuid.uuid4()}/{filename}"
    url = build_s3_client().generate_presigned_url(
        "put_object",
        Params={
            "Bucket": settings.AWS_UPLOAD_BUCKET,
            "Key": key,
            "ContentType": content_type,
        },
        ExpiresIn=300,  # 5 minutes
    )
    return {"upload_url": url, "key": key}

Three deliberate choices here:

  • signature_version="s3v4" — regions launched after 2014 (including ap-south-1) only accept Signature Version 4. Older boto3 defaults or copy-pasted snippets using v2 produce URLs that fail with SignatureDoesNotMatch.
  • The regional endpoint_url — without it, boto3 may sign the URL against the global endpoint (s3.amazonaws.com). The signature is then valid for the wrong host, and the upload fails or gets a redirect the browser won’t follow for PUT.
  • Server-generated key with a UUID — never let the client choose the full object key. That’s how you get overwritten files and path shenanigans.

The DRF view is thin:

# uploads/views.py
from rest_framework.response import Response
from rest_framework.views import APIView

from .services import generate_upload_url

ALLOWED_TYPES = {"application/pdf", "image/png", "image/jpeg"}

class PresignUploadView(APIView):
    def post(self, request):
        filename = request.data.get("filename", "")
        content_type = request.data.get("content_type", "")
        if content_type not in ALLOWED_TYPES:
            return Response({"error": "Unsupported file type."}, status=400)
        return Response(generate_upload_url(filename, content_type))

Because ContentType is baked into the signature, the browser must send the same Content-Type header — send a different one and S3 rejects the upload. That’s a feature: the URL can’t be reused to upload a different kind of file.

Step 3 — The CORS configuration

The browser upload is a cross-origin PUT, so the bucket needs a CORS policy. Skipping this is the second most common failure — the preflight OPTIONS request dies before a single byte is uploaded.

[
  {
    "AllowedOrigins": ["http://localhost:8000", "https://yourapp.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Apply it:

aws s3api put-bucket-cors \
  --bucket awspractice-s3-demo \
  --cors-configuration file://cors.json

Keep AllowedOrigins exact — no * in production. ExposeHeaders: ["ETag"] lets your JavaScript read the ETag from the response, useful as an upload receipt.

Step 4 — The browser upload

No SDK needed on the frontend — it’s one fetch:

async function uploadFile(file) {
  // 1. Ask Django for a presigned URL
  const presign = await fetch("/api/uploads/presign/", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, content_type: file.type }),
  }).then((r) => r.json());

  // 2. PUT the file directly to S3
  const res = await fetch(presign.upload_url, {
    method: "PUT",
    headers: { "Content-Type": file.type },  // must match the signed type
    body: file,
  });

  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
  return presign.key;  // store this reference in your DB
}

After a successful upload, send presign.key back to Django to create the database record. If you need hard guarantees the file actually landed, have Django verify with head_object — or subscribe to S3 Event Notifications and confirm asynchronously.

Troubleshooting

SignatureDoesNotMatch — almost always one of: missing signature_version="s3v4", signing against the global endpoint instead of your region, or the browser sending a Content-Type that differs from the signed one. Check in that order.

CORS error in the console — the preflight failed. Confirm the CORS policy is on the bucket (it’s not an IAM thing), the origin matches exactly (scheme + host + port), and PUT is in AllowedMethods.

403 on upload — the IAM identity behind the presigning credentials lacks s3:PutObject on that key prefix. The URL generation itself never checks permissions — boto3 will happily sign a URL that S3 later rejects.

URL works in curl but not the browser — you’re probably sending extra headers from JavaScript that weren’t part of the signature. Send only Content-Type.

Limitations worth knowing

Presigned PUT can pin the content type but cannot enforce a maximum file size. If you need size limits or stricter conditions enforced by S3 itself, use presigned POST, which supports a policy document with content-length-range. For most authenticated-user upload flows, PUT plus a short expiry and a scoped IAM policy is the right trade-off — and it’s half the code.


This pattern scales further than you’d think: add S3 Event Notifications on the uploads/ prefix, fan out with SNS, and you’ve got the front door of an event-driven ingestion pipeline — which is exactly where I’d take this in a follow-up post.