When a web application needs to handle file uploads from users at scale, the architectural decision that produces the largest savings is moving the upload bandwidth off the application servers. Pre-signed URLs let you do this cleanly: the browser uploads directly to S3, the application server never sees the file bytes, and bandwidth costs scale with storage costs rather than with application server count.
This article walks through how the pattern works, what the implementation looks like, and what the security considerations are.

Photo by Brett Sayles on Pexels
The Pattern in One Paragraph
The browser asks the application server "I want to upload a file." The application server checks that the user is authorized to upload, then generates a pre-signed URL for an S3 path scoped to that user. The application server returns the URL to the browser. The browser uses that URL to PUT the file directly to S3. S3 stores the file. After the upload completes, the browser notifies the application server "the upload is done at path X." The application server creates a database record pointing at the S3 object.
Bandwidth never passes through your application server. The S3 service handles the upload entirely. Your application server only generates the URL and tracks metadata.
Step 1: Configure the S3 Bucket
The bucket holds the uploaded files. A few configuration decisions matter.
The CORS policy needs to allow the browser to upload directly. The CORS configuration permits the PUT method from your application's origin, allows the relevant headers (Content-Type, x-amz-* headers), and exposes ETag in responses so the browser can confirm the upload.
The bucket policy controls public access. For user-uploaded content, the bucket should not be world-readable by default. Either keep the bucket fully private and serve files through your application (which adds back the bandwidth cost), or use signed download URLs that grant time-limited read access.
The lifecycle rules handle cleanup. Incomplete multipart uploads should be cleaned up after a fixed number of days (7 is common). Abandoned objects can be moved to a cheaper storage class or deleted entirely after a policy-defined period.
The AWS S3 documentation covers all of these in detail. The configuration takes about an hour to get right the first time and rarely needs changes after.
Step 2: Generate the Pre-Signed URL
The application server generates the URL using the AWS SDK. The exact code varies by language, but the general shape is the same:
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({ region: 'us-east-1' });
async function generateUploadUrl(userId, filename, contentType) {
// Authorization happens here, before generating the URL
const path = `uploads/${userId}/${randomId()}/${filename}`;
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: path,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
return { url, path };
}
The expiration is short (15 minutes is common) because the URL is sensitive: anyone with the URL can upload to that path until it expires. If the browser does not complete the upload within the window, the URL becomes invalid and a new one has to be generated.
The path includes the user ID to ensure uploads from different users are isolated even at the storage layer. The path also includes a random ID to prevent users from overwriting their own previous uploads accidentally.
Step 3: Upload From the Browser
With the URL in hand, the browser uploads using a PUT request:
async function uploadFile(file, url) {
const response = await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.headers.get('ETag');
}
For small files (under 100MB on stable connections), this single-request pattern works fine. For larger files or unreliable connections, the next step is multipart uploads, which split the file into chunks. The browser uploads each chunk separately, and S3 assembles them into a single object at the end.
The MDN documentation on the Fetch API covers progress events and abort signals, which are useful for showing upload progress and supporting cancellation.
Step 4: Notify the Application Server
When the upload completes, the browser notifies the application server. The notification typically includes the path, the file size, and the ETag returned by S3.
The application server validates the notification: confirm that the file actually exists at the claimed path (a HEAD request to S3), confirm that the size matches the original request, and create a database record linking the user to the S3 object.
For applications that need post-upload processing (virus scanning, image resizing, video transcoding, content moderation), this is the trigger point. The application server enqueues a processing job and shows the user a "processing" state until the job completes.
S3 also supports event notifications via SNS, SQS, or Lambda. The application server can subscribe to ObjectCreated events on the bucket and receive notifications directly from S3 when uploads complete, which is more reliable than the browser-side notification.
Step 5: Use Multipart Uploads for Larger Files
For files over a few hundred MB, S3's multipart upload protocol is the right choice. The protocol is built into S3 and handles resumability natively.
The flow is:
- The browser asks the application server for a multipart upload session.
- The application server creates a multipart upload via the CreateMultipartUpload API and returns the UploadId to the browser.
- The browser splits the file into chunks (5MB minimum per chunk, 10,000 chunks maximum).
- For each chunk, the browser requests a pre-signed URL from the application server (or the application server pre-generates URLs for all chunks at once).
- The browser uploads each chunk and collects the ETag returned for each.
- When all chunks are uploaded, the browser calls CompleteMultipartUpload on the application server, which assembles the chunks into a single S3 object.
The protocol supports resumability natively. If the browser disconnects mid-upload, the partial multipart upload remains on S3. When the browser reconnects, it can call ListParts to see which chunks have been uploaded and resume from where it left off.
The full architectural framing including how to combine multipart with the application's authorization and notification layers is covered at the longer guide: https://137foundry.com/articles/how-to-build-resumable-file-uploads-for-large-files
Security Considerations
A few specific security issues come up with direct browser uploads.
Content type spoofing. The browser declares the content type in the upload, but a malicious user can declare any content type for any file. The application server should validate the actual content of the file after upload (using a library that inspects file headers, like file-type for Node.js) rather than trusting the declared type.
Path traversal. If the path is constructed using user-supplied input, it must be sanitized to prevent users from uploading to arbitrary paths. A random ID generated by the server is safer than using the user-supplied filename directly in the path.
URL leakage. Pre-signed URLs are bearer tokens: anyone with the URL can upload until it expires. Use short expiration times (15 minutes is typical), do not log the URLs anywhere persistent, and do not include them in URLs that might be cached by browsers or proxies.
Quota enforcement. Without server-side enforcement, a malicious user could upload arbitrarily large files or arbitrary numbers of files. The application server should track upload quotas per user and reject pre-signed URL requests that would exceed the quota.
The OWASP file upload cheat sheet covers the broader security considerations for file uploads in web applications, including content validation, anti-virus scanning, and access control.
Cost Considerations
S3 charges for storage and for requests. A direct browser upload pattern produces:
- One PUT request per chunk (for multipart) or per file (for single-request).
- One CompleteMultipartUpload request per multipart upload.
- Storage charges for the file size.
Compared to mediated uploads (browser to application server to S3), the direct pattern eliminates the bandwidth charges for the application server's outbound traffic to S3. For high-volume applications, this is the largest cost savings.
For applications with strong patterns around upload size (e.g., always under 50MB), the request cost per chunk does not add up to much. For applications with predictable large uploads (multi-GB files), the request cost should be modeled but is usually small compared to storage.
When to Use This Pattern
Direct browser-to-S3 uploads are the right choice when:
- The application handles user-uploaded content at any meaningful scale.
- Bandwidth costs through the application server are a concern.
- Post-upload validation can run asynchronously rather than during the upload.
- The application is comfortable depending on S3 (or an S3-compatible service) as the storage layer.
Mediated uploads are still the right choice when:
- File content must be validated during the upload (uncommon, but exists).
- The application needs to transform content on the fly during upload.
- Storage credentials cannot be exposed even via pre-signed URLs (unusual).
- The application is using a non-S3-compatible storage backend.
Most teams that have shipped both patterns end up preferring direct uploads for the cost and scalability benefits.

Photo by Rafael Minguet Delgado on Pexels
Where the 137Foundry Team Helps
The 137Foundry web development team has built upload pipelines on this pattern for SaaS platforms, document tools, and creative applications. The pattern is well-understood and the implementation is mechanical once the architectural decisions are settled. The 137Foundry homepage describes the broader application engineering work that fits around upload infrastructure.
A Closing Note on Build vs Buy
For most teams, building the direct-upload pattern with the AWS SDK is the right choice because the implementation is small and the operational burden is low. There are also third-party services (Uppy, Filestack, Cloudinary) that wrap direct-upload patterns in higher-level APIs.
The tradeoff is the usual one. Build it yourself for control and cost optimization at scale. Use a third-party service for time savings at smaller scale. For applications that handle a meaningful share of their revenue through uploaded files, the build-it-yourself path usually wins on cost in the long run. For applications where uploads are a minor feature, the buy path often wins on calendar time.
Top comments (0)