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.
All services used in this lesson are covered by the AWS Free Tier.
AWS Services Used
Learning Outcomes
By the end of this lesson, you will be able to:
- Let a browser upload directly to S3 without giving the browser AWS credentials.
- Generate a temporary upload ticket from Lambda using a presigned S3 request.
- Configure the right CORS settings on both API Gateway and the S3 bucket.
- Explain why direct browser upload to S3 is cleaner than sending the file through your API.
- 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:
- The browser asks your API for a temporary upload ticket.
- Lambda generates a presigned S3 upload request.
- The browser uploads the file directly to S3.
- Your existing S3
ObjectCreatednotification pipeline continues to run afterward.
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
| Step | Success Condition |
|---|---|
| Update API Gateway CORS | Frontend can call POST /upload-url |
| Update S3 bucket CORS | Browser can POST directly to S3 |
| Create upload-ticket Lambda | Function returns URL, fields, and object key |
| Add s3:PutObject permission | Lambda can generate working upload tickets |
| Create POST /upload-url | Route exists and is connected |
| Upload file | S3 accepts the direct browser upload |
| Refresh dashboard | New 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
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.