AWS Builder Center
Stop Uploading Files Through Your Backend: Use S3 Presigned URLs Instead

Stop Uploading Files Through Your Backend: Use S3 Presigned URLs Instead

Streaming large file uploads through your API server chokes memory, wastes compute, and limits scale. Here is how S3 Presigned URLs offload the heavy lifting directly to AWS.

A classic mistake when building web applications is accepting file uploads directly on your backend API server (whether Flask, Express, or Django) and then forwarding those bytes to Amazon S3.
It works fine in local testing with small avatars. But in production, this pattern introduces major bottlenecks:
  • Memory & Buffer Bloat: Buffering multi-megabyte files (PDFs, images, videos) in memory or temp disks exhausts server resources.
  • Connection Starvation: Slow client internet speeds keep backend worker threads occupied for seconds or minutes just waiting for bytes to finish streaming.
  • Compute Costs: You pay for compute runtime simply acting as a dumb proxy pipe to S3.
The modern cloud-native pattern is straightforward: let the client upload directly to S3 using Presigned URLs.

How the Presigned URL Flow Works

Instead of routing data through your server, your API acts purely as an authorization gatekeeper:
1
2
3
4
5
6
Client (Browser/Mobile) ── Request Upload URL ──> Your API (Lambda/Server)
│
▼ Generates signed URL via IAM
Client <── Returns short-lived signed S3 URL ────┘
│
└── PUT file directly ──> Amazon S3
  • Step 1: The frontend requests permission to upload a file (e.g., image.png, size: 5MB).
  • Step 2: Your lightweight backend checks user authentication/authorization, generates a time-limited Presigned URL using the AWS SDK, and sends it back to the client.
  • Step 3: The client issues a standard HTTP PUT request with the file directly to the S3 URL. The server never touches a single byte of the file.

Generating a Presigned URL (Python / boto3 Example)

Your backend logic stays feather-light, taking milliseconds to run:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import boto3
from botocore.config import Config

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

def get_upload_url(user_id: str, filename: str):
s3_key = f"uploads/{user_id}/{filename}"

# URL expires in 5 minutes (300 seconds)
presigned_url = s3_client.generate_presigned_url(
ClientMethod='put_object',
Params={
'Bucket': 'my-secure-upload-bucket',
'Key': s3_key,
'ContentType': 'image/png' # Lock down allowed MIME type
},
ExpiresIn=300
)
return presigned_url

Frontend Upload (JavaScript)

The browser uses standard fetch to push the file directly to S3:
1
2
3
4
5
6
7
8
9
10
11
12
13
async function uploadFile(file, presignedUrl) {
const response = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});

if (response.ok) {
console.log('Upload successful directly to S3!');
}
}

Key Security & Production Considerations

  • Configure S3 CORS: Add a Cross-Origin Resource Sharing (CORS) policy on your S3 bucket allowing PUT requests from your frontend domain.
  • Enforce Strict Expiration: Keep ExpiresIn short (3 to 5 minutes). The client only needs the URL long enough to initiate the upload.
  • Lock the Content-Type: Always specify the exact MIME type in the presigned URL parameters so clients cannot upload arbitrary executable scripts.
  • Trigger Post-Processing Asynchronously: Need to resize an image or extract text with OCR? Do not make the user wait. Configure an S3 Event Notification to trigger an AWS Lambda worker the instant the upload completes.
Offloading file streaming directly to S3 frees up your backend to focus strictly on business logic and keeps your infrastructure fast, scalable, and cheap.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article