Skip to content
Back to blog
  • AWS
  • Engineering

S3 Presigned URL FastAPI: Secure Client-Side Uploads

Offload file handling from your API. Learn how to generate secure, time-limited S3 presigned URLs in FastAPI using boto3 for direct client uploads.

MMuhammad Muneeb4 min read

Routing multi-megabyte user file uploads through an application server is a common design mistake. Streaming large payloads through your API consumes network bandwidth, holds worker processes open, and inflates memory usage. Implementing an s3 presigned url fastapi workflow eliminates this bottleneck entirely by allowing the client to ship binary data directly to an AWS bucket without touching your core application runtime.

Much like handling heavy asynchronous computations by offloading to background jobs with BullMQ, large files require architectural separation. Instead of acting as an intermediary proxy, your backend acts solely as an authorization gatekeeper. In this guide, we will implement direct uploads using boto3 to sign time-limited upload parameters with precise size and MIME type restrictions.

Why Direct AWS S3 File Upload Beats Server Buffering

A standard multipart form upload to an API forces the server to buffer chunks in RAM or write temporary files to local disk before transferring them upstream to cloud storage. When multiple concurrent users upload 20MB files, a cluster running Uvicorn workers quickly runs out of resources or suffers severe event loop delays.

When you offload an aws s3 file upload directly to S3, your API only deals with JSON metadata: validating the requester session, sanitizing the intended key, and signing the request. S3 handles the heavy payload ingress, retry handling, and network buffering. Your server stays lightweight and responsive, regardless of upload volume.

Presigned PUT vs Presigned POST

AWS offers two primary methods for presigned uploads: standard HTTP PUT URLs and presigned POST policies. While a simple PUT URL is quick to implement, it provides limited control over what the client actually transmits once the link is signed. The client could theoretically send an unexpected file type or an enormous payload unless restricted by complex IAM bucket policies.

A boto3 presigned url generated via generate_presigned_post solves this by producing a cryptographically signed policy document alongside form fields. This mechanism allows you to strictly enforce conditions: you can mandate an exact content type and an explicit content-length-range. If a client attempts to bypass these rules, AWS rejects the upload before writing a single byte to storage.

Configuring the S3 Presigned URL FastAPI Endpoint

To keep the implementation maintainable, we separate client payload validation, S3 service generation, and the route handler. We will use Pydantic models to validate the incoming request, generate a unique key using a UUID to prevent collisions, and restrict file sizes between 1 byte and 10 megabytes.

services/s3.py
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from typing import Dict, Any, Optional
import uuid

s3_client = boto3.client(
    "s3",
    region_name="us-east-1",
    config=Config(signature_version="s3v4")
)

BUCKET_NAME = "my-production-upload-bucket"
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10 MB


def generate_upload_policy(file_extension: str, content_type: str) -> Optional[Dict[str, Any]]:
    clean_ext = file_extension.lstrip(".").lower()
    object_key = f"uploads/{uuid.uuid4().hex}.{clean_ext}"

    conditions = [
        {"bucket": BUCKET_NAME},
        ["starts-with", "$key", "uploads/"],
        {"Content-Type": content_type},
        ["content-length-range", 1, MAX_FILE_SIZE_BYTES]
    ]

    fields = {
        "Content-Type": content_type
    }

    try:
        response = s3_client.generate_presigned_post(
            Bucket=BUCKET_NAME,
            Key=object_key,
            Fields=fields,
            Conditions=conditions,
            ExpiresIn=300  # 5 minutes validity
        )
        return response
    except ClientError:
        return None

Now we wrap this logic inside a FastAPI route. We ensure incoming requests specify an allowed file extension and MIME type before issuing signed credentials.

routers/uploads.py
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, field_validator
from services.s3 import generate_upload_policy

router = APIRouter(prefix="/uploads", tags=["Uploads"])

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


class PresignedUrlRequest(BaseModel):
    content_type: str
    extension: str

    @field_validator("content_type")
    def validate_content_type(cls, v: str) -> str:
        if v not in ALLOWED_TYPES:
            raise ValueError("Unsupported MIME type")
        return v


class PresignedUrlResponse(BaseModel):
    url: str
    fields: dict[str, str]


@router.post(
    "/presigned-url",
    response_model=PresignedUrlResponse,
    status_code=status.HTTP_201_CREATED
)
def request_presigned_url(payload: PresignedUrlRequest):
    expected_ext = ALLOWED_TYPES[payload.content_type]
    clean_ext = payload.extension.lstrip(".").lower()
    
    if clean_ext != expected_ext:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Extension .{clean_ext} does not match content type {payload.content_type}"
        )

    presigned_data = generate_upload_policy(
        file_extension=clean_ext,
        content_type=payload.content_type
    )

    if not presigned_data:
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="Could not generate storage credentials"
        )

    return presigned_data

Executing the Direct Upload from the Client

When building an interface that performs a fastapi s3 upload, the client must format the request as multipart/form-data. The fields returned in the policy dictionary must be appended to the form before the binary file object itself. AWS requires the file field to be the final parameter in the form payload.

client/uploader.ts
interface PresignedResponse {
  url: string;
  fields: Record<string, string>;
}

export async function uploadFileToS3(file: File): Promise<string> {
  const tokenRes = await fetch("/uploads/presigned-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      content_type: file.type,
      extension: file.name.split(".").pop() || ""
    })
  });

  if (!tokenRes.ok) {
    throw new Error("Failed to acquire presigned upload URL");
  }

  const { url, fields }: PresignedResponse = await tokenRes.json();

  const formData = new FormData();
  for (const [key, value] of Object.entries(fields)) {
    formData.append(key, value);
  }
  // The file parameter must be added last for S3 form uploads
  formData.append("file", file);

  const uploadRes = await fetch(url, {
    method: "POST",
    body: formData
  });

  if (!uploadRes.ok) {
    throw new Error("Direct upload to S3 failed");
  }

  return `${url}/${fields.key}`;
}

Securing the Storage Bucket

A secure upload endpoint requires matching safety rules in the target S3 bucket. Ensure that your bucket blocks all public access by default. Uploaded objects must remain private, accessible only via signed read URLs or backend service credentials.

Additionally, configure CORS on the bucket to allow POST requests only from your explicit frontend origins. Just as we avoid naive request handling when designing a scalable serverless API on AWS Lambda, storage security requires least-privilege IAM roles. The IAM role running your FastAPI application needs only s3:PutObject permissions scoped to the specific bucket prefix.

Validating Uploads Asynchronously

Because the file bypasses your API server, you must never trust that an upload completed simply because the client claims it did. Do not update database records immediately after issuing a presigned URL.

The cleanest pattern is listening for S3 event notifications emitted on object creation. You can route an S3 ObjectCreated event to an SQS queue or invoke a webhook. For patterns on verifying and processing external event payloads safely, see our guide on handling FastAPI webhooks with HMAC and idempotent retries. Your background listener can inspect the object metadata, execute malware scans or thumbnail generation, and mark the asset as verified in your primary database.

Adopting direct client-side S3 uploads keeps your FastAPI application snappy and scalable. By shifting the network and memory burden of raw file transfers to AWS S3, your API remains dedicated to executing business logic while maintaining complete control over file constraints and access boundaries.

Related posts