AWSS3 BucketFastAPIReactMongoDB

    My First Hands-on Experience with AWS S3 Presigned URL Uploads

    I recently started learning AWS S3 beyond the basics. Instead of only reading the documentation, I wanted to understand how file uploads actually work in real applications. To lear...

    Jul 20, 2026
    6 min read
    66 views
    My First Hands-on Experience with AWS S3 Presigned URL Uploads

    I recently started learning AWS S3 beyond the basics. Instead of only reading the documentation, I wanted to understand how file uploads actually work in real applications.

    To learn this, I created a small project using FastAPI, React (TanStack Router), and AWS S3. My goal wasn't to build a production feature—it was to explore the complete upload process, understand the architecture, and debug the kinds of issues developers commonly face.

    During this learning project, I implemented file uploads using Presigned URLs, experimented with different approaches, and fixed several problems such as CORS errors, signature mismatches, and incorrect content types.

    This article summarizes everything I learned along the way.


    Traditional File Upload

    When I first started, my upload flow looked like this:

    text
    Browser
        │
        │ Upload File
        ▼
    FastAPI Backend
        │
        │ Upload using boto3
        ▼
    AWS S3

    The browser sends the entire file to the backend.

    The backend receives the file and uploads it to AWS S3.

    Why this approach isn't ideal

    Imagine a user uploads a 100 MB video.

    The file travels twice:

    1. Browser → Backend
    2. Backend → AWS S3

    This means:

    • More backend bandwidth
    • More memory usage
    • Slower uploads
    • Backend becomes responsible for handling large files

    It works well for small applications, but it doesn't scale efficiently.


    Learning About Presigned URLs

    Then I learned about Presigned URLs.

    Instead of sending the file through the backend, the backend simply generates a temporary upload URL.

    The browser uploads the file directly to AWS S3.

    The flow becomes:

    text
    Browser
        │
        │ Request Upload URL
        ▼
    FastAPI Backend
        │
        │ Generate Presigned URL
        ▼
    Browser
        │
        │ Upload File Directly
        ▼
    AWS S3

    Notice something important.

    The backend never receives the actual file.

    It only creates a temporary upload URL with permission to upload.

    This approach is much faster and reduces the workload on the backend.


    My Final Upload Flow

    The implementation finally became a simple three-step process.

    Step 1 — Request a Presigned URL

    The frontend first requests a Presigned URL from the backend.

    Example request:

    json
    {
      "filename": "profile.png",
      "file_type": "image",
      "content_type": "image/png"
    }

    The backend responds with:

    • Upload URL
    • Object Key
    • File URL

    Step 2 — Upload Directly to AWS S3

    The browser uploads the file directly to AWS S3.

    typescript
    await fetch(upload_url, {
      method: "PUT",
      headers: {
        "Content-Type": file.type,
      },
      body: file,
    })

    The backend is completely skipped during the upload.


    Step 3 — Save Metadata

    Once the upload succeeds, the frontend informs the backend.

    The backend stores only the file information, such as:

    • Title
    • Object Key
    • File URL
    • File Type

    The actual file is already stored inside S3.


    Problems I Faced

    Like most real-world implementations, everything didn't work on the first attempt.

    Here are the issues I faced while building this feature.


    Problem 1 — 403 Forbidden (CORS Error)

    The upload kept failing with:

    text
    403 Forbidden

    When I opened the browser's Developer Tools, I noticed that the browser was sending an OPTIONS request before the actual upload.

    This is called a CORS Preflight Request.

    Since my S3 bucket wasn't configured to allow requests from my frontend, AWS rejected the request.

    Solution

    I configured CORS on my S3 bucket.

    json
    [
      {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
        "AllowedOrigins": ["*"],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 3000
      }
    ]

    After updating the bucket CORS configuration, the browser was allowed to upload files directly.


    Problem 2 — Signature Mismatch

    The next issue was another 403 Forbidden error.

    The backend successfully generated a Presigned URL, but AWS rejected the upload request.

    After debugging, I found the reason.

    While generating the Presigned URL, I had included extra parameters like:

    python
    ACL="public-read"

    AWS signs every parameter used while generating the URL.

    That means the upload request must include exactly the same headers.

    My frontend wasn't sending:

    text
    x-amz-acl: public-read

    Because of that, AWS calculated a different signature and rejected the request.

    Solution

    I simplified the parameters while generating the Presigned URL.

    python
    params = {
        "Bucket": bucket_name,
        "Key": key,
        "ContentType": content_type,
    }

    Then my frontend only needed to send:

    typescript
    headers: {
        "Content-Type": file.type
    }

    After matching the headers correctly, the upload worked without any issues.


    Problem 3 — Images Download Instead of Opening

    After uploading images successfully, I noticed another problem.

    Clicking the file URL downloaded the image instead of displaying it in the browser.

    The reason was that AWS S3 stored the file as:

    text
    application/octet-stream

    Since the browser didn't know the actual file type, it treated it as a binary download.

    Solution

    While generating the Presigned URL, I included the original content type.

    Backend:

    python
    params["ContentType"] = content_type

    Frontend:

    typescript
    headers: {
        "Content-Type": file.type
    }

    Now AWS stored files with the correct MIME type, such as:

    text
    image/png

    or

    text
    image/jpeg

    As a result, browsers displayed the image correctly instead of downloading it.


    Final Architecture

    The final upload flow became:

    text
    Browser
          │
          │ Request Presigned URL
          ▼
    FastAPI Backend
          │
          │ Generate Upload URL
          ▼
    Browser
          │
          │ Direct Upload
          ▼
    AWS S3
          │
          │ Upload Successful
          ▼
    Browser
          │
          │ Save Metadata
          ▼
    FastAPI Database

    Now the backend is responsible only for:

    • Authenticating users
    • Generating Presigned URLs
    • Storing file metadata

    AWS S3 is responsible for storing the actual files.


    What I Learned

    This implementation helped me understand several important AWS concepts.

    • How Presigned URLs work
    • Why direct uploads are preferred in production
    • How browser CORS requests work
    • Why signed request headers must match exactly
    • Why setting the correct Content-Type is important
    • How to separate file storage from application data

    Final Thoughts

    Before implementing this feature, I thought uploading files to AWS S3 simply meant calling boto3.upload_file() from the backend.

    After building a Presigned URL workflow, I understood why many production applications let browsers upload files directly to S3.

    The backend becomes lighter, uploads are faster, and the architecture is much easier to scale.

    More importantly, debugging each issue taught me far more about AWS S3 than simply reading the documentation.

    J
    Written by

    Jobi S S

    Portfolio

    admin

    Sharing technical insights, engineering concepts, and practical modern software development guides.

    Community Discussion

    Enjoyed this read? Show your support or share your thoughts.

    Comments (0)

    No comments yet. Be the first to comment!

    📬 Enjoyed this article?

    Get new posts on Django, FastAPI, and system design straight to your inbox. No spam — unsubscribe whenever you want.