Skip to main content
Skip to main content
Still in beta — questions, comments or suggestions? aramb@aramb.dev

Add Uploads from the Browser with Presigned URLs

Let a browser upload directly to S3 without giving it AWS credentials by generating a temporary upload ticket from Lambda.

35 min
Introductory
AWS Free TierFREE TIER

All services used in this lesson are covered by the AWS Free Tier.

AWS Services Used

S312-month free tierLambdaAlways free tierAPI Gateway12-month free tier

Learning Outcomes

By the end of this lesson, you will be able to:

  1. Let a browser upload directly to S3 without giving the browser AWS credentials.
  2. Generate a temporary upload ticket from Lambda using a presigned S3 request.
  3. Configure the right CORS settings on both API Gateway and the S3 bucket.
  4. Explain why direct browser upload to S3 is cleaner than sending the file through your API.
  5. Keep the bucket private while still allowing a controlled upload path.

The Core Idea

A presigned S3 request lets someone upload an object to your bucket without having AWS credentials of their own. The upload is limited by the permissions of the AWS principal that created the presigned request.

For a browser-based upload form, the clean pattern is:

  1. The browser asks your API for a temporary upload ticket.
  2. Lambda generates a presigned S3 upload request.
  3. The browser uploads the file directly to S3.
  4. Your existing S3 ObjectCreated notification pipeline continues to run afterward.
Full Direct Upload Lifecycle

Why this pattern is better

This pattern is useful because your API does not have to receive and forward the entire file. Instead, the browser uploads directly to S3, while your backend only issues a short-lived permission for that one upload. This keeps the bucket private and makes your serverless architecture cleaner.


Part 1: Update API Gateway CORS

Your frontend already calls GET routes. Now it also needs to call a POST route to request an upload ticket.

Update your HTTP API CORS settings so they allow:

  • Your frontend origin
  • Methods: GET, POST
  • Headers: Content-Type

Part 2: Configure S3 Bucket CORS

Because the browser will now talk directly to S3, the bucket also needs a CORS rule. Enabling CORS does not bypass normal bucket policies or permissions.

Use a bucket CORS configuration like this:

[
  {
    "AllowedOrigins": ["YOUR_FRONTEND_ORIGIN"],
    "AllowedMethods": ["POST", "GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"]
  }
]

Part 3: Create the Upload-Ticket Lambda Function

Create a new Lambda function that generates a presigned POST for S3. Use this Python code:

import json
import os
import uuid
import boto3

s3_client = boto3.client("s3")

UPLOAD_BUCKET = os.environ["UPLOAD_BUCKET"]
UPLOAD_PREFIX = os.environ.get("UPLOAD_PREFIX", "incoming/")
UPLOAD_EXPIRES_SECONDS = int(os.environ.get("UPLOAD_EXPIRES_SECONDS", "900"))

def lambda_handler(event, context):
    try:
        body = json.loads(event.get("body") or "{}")
    except json.JSONDecodeError:
        return {
            "statusCode": 400,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"error": "Invalid JSON body"})
        }

    filename = body.get("filename")
    if not filename:
        return {
            "statusCode": 400,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"error": "Missing required field: filename"})
        }

    # Extract filename only to prevent path injection
    safe_name = filename.split("/")[-1].split("\\")[-1]
    object_key = f"{UPLOAD_PREFIX}{uuid.uuid4()}-{safe_name}"

    post = s3_client.generate_presigned_post(
        Bucket=UPLOAD_BUCKET,
        Key=object_key,
        ExpiresIn=UPLOAD_EXPIRES_SECONDS
    )

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({
            "upload": post,
            "object_key": object_key,
            "bucket": UPLOAD_BUCKET,
            "expires_in": UPLOAD_EXPIRES_SECONDS
        })
    }

Part 4: Give the Lambda Function Permissions

The Lambda function’s execution role needs s3:PutObject on the upload bucket or prefix you are targeting.

{
  "Sid": "AllowPresignedUploads",
  "Effect": "Allow",
  "Action": ["s3:PutObject"],
  "Resource": "arn:aws:s3:::YOUR_UPLOAD_BUCKET/incoming/*"
}

Part 5: Create the API Route

Add a new HTTP API route: POST /upload-url and integrate it with your new Lambda function.


Part 6: Update the Frontend

Add an upload form and JavaScript to your frontend. The script will first request an upload ticket from your API, then use that ticket to POST the file directly to S3.


Part 7: What happens after the upload

Because the browser is still creating a normal S3 object, your existing ObjectCreated notification path still applies. Your earlier incoming/ prefix pattern still helps avoid recursive loops.


Lab Checklist

StepSuccess Condition
Update API Gateway CORSFrontend can call POST /upload-url
Update S3 bucket CORSBrowser can POST directly to S3
Create upload-ticket LambdaFunction returns URL, fields, and object key
Add s3:PutObject permissionLambda can generate working upload tickets
Create POST /upload-urlRoute exists and is connected
Upload fileS3 accepts the direct browser upload
Refresh dashboardNew file appears after metadata pipeline runs

Micro-activity 1: Reflection

Think about it

After the lab, reflect: What filename did you choose? What object key was generated? Did the browser upload directly to S3? Did the upload return success?


Micro-activity 2: Upload Flow Concepts

Micro-Activity

Match each part of the browser upload flow

Examples

Choose one, then match it on the right

Characteristics

Select an example first

0 of 5 matched so far.


Summary

In this lesson, you added the missing write path for your dashboard: the browser requests a temporary upload ticket, then uploads directly to S3 using a presigned POST. This keeps your API efficient and your storage secure.


Quiz

Knowledge Check
1 / 5

What is the main benefit of a presigned upload request?