How Browser Uploads Work with Pre-Signed S3 URLs
Browser uploads with pre-signed S3 URLs solve a common app problem: users need to upload files, but your server should not handle every byte of every upload.
The pattern is simple:
- Your app authenticates the user.
- Your backend creates a short-lived upload URL for one object key.
- The browser uploads the file directly to object storage.
- Your backend stores the final object key in your database.
Your server still controls who can upload and where the file goes. It just stops being the file-moving middleman. Nice little architecture win.
What is a pre-signed URL?
A pre-signed URL is a temporary URL that carries enough signature information to perform one specific storage action. For browser uploads, that action is usually PutObject.
AWS describes presigned URLs as URLs that use security credentials to grant time-limited permission. For uploads, the generated URL lets someone upload an object without receiving the underlying AWS or S3-compatible access keys.
That last part matters. You never put secret keys in the browser. The browser gets a time-limited URL, not permanent credentials.
With Filebase, the same S3-compatible model works through the Filebase S3 API. Filebase’s AWS SDK for JavaScript quickstart shows the SDK working by setting two client values:
endpoint: "https://s3.filebase.io"
region: "auto"
After that, normal S3 commands and presigning helpers can target Filebase.
The browser upload flow
Here is the usual flow in a web app.
1. The browser asks your backend for an upload URL
The browser sends metadata to your API:
{
"fileName": "profile-photo.png",
"contentType": "image/png",
"size": 384000
}
Your backend checks the user session, validates the file type and size, decides the object key, and creates a pre-signed URL.
That object key should come from your server, not blindly from the browser. A good key might look like:
users/user_123/uploads/2026/07/profile-photo.png
2. The backend signs a PutObject request
In a Filebase-backed Node app, the signing code can use the AWS SDK:
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
endpoint: "https://s3.filebase.io",
region: "auto",
credentials: {
accessKeyId: process.env.FILEBASE_KEY!,
secretAccessKey: process.env.FILEBASE_SECRET!,
},
});
export async function createUploadUrl() {
const command = new PutObjectCommand({
Bucket: "uploads",
Key: "users/user_123/profile-photo.png",
ContentType: "image/png",
});
return getSignedUrl(s3, command, { expiresIn: 900 });
}
That returns a URL the browser can use for the next 15 minutes. Keep upload windows short. Long-lived upload URLs are like leaving the side door open because “it is probably fine.” Technically possible. Emotionally suspicious.
3. The browser uploads directly
The frontend uses fetch with PUT:
async function uploadFile(file: File, signedUrl: string) {
const response = await fetch(signedUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});
if (!response.ok) {
throw new Error("Upload failed");
}
}
The Content-Type header needs to match what was signed. AWS’s upload docs for presigned URLs call this out because mismatched headers can cause SignatureDoesNotMatch errors.
4. Your app confirms the upload
After the upload succeeds, the browser should tell your backend which object key was uploaded. The backend can then save that key, mark the upload complete, or run follow-up processing.
Do not treat “the browser said it uploaded” as the only source of truth. For sensitive workflows, call HeadObject from the backend to confirm the object exists and matches expected metadata.
CORS is the part everyone forgets
For browser uploads, pre-signed URLs are only half the setup. The bucket also needs CORS.
CORS tells the storage service which web origins can make browser requests and which methods are allowed. AWS explains that CORS lets client-side apps loaded from one domain interact with resources in another domain. Filebase’s CORS docs say the same practical thing for Filebase buckets: without a CORS configuration, browsers refuse cross-origin reads and uploads.
For a single-page app, Filebase shows a CORS rule shaped like this:
{
"CORSRules": [
{
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedOrigins": ["https://app.example.com"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 3000
}
]
}
The important parts:
- Include
PUTfor direct uploads. - Use your real app origin in production.
- Expose
ETagif your frontend reads it. - Allow the headers your browser sends.
If CORS is wrong, the browser may block the request before your upload even gets a fair chance. Classic “the backend is fine, the browser is mad” territory.
Are pre-signed URLs safe?
They are safe when scoped and treated like temporary secrets.
A pre-signed URL should be:
- Short-lived
- Created only after user authorization
- Limited to a specific bucket, key, method, and headers
- Sent over HTTPS
- Stored carefully if you log requests
Anyone with the URL can use it until it expires, so do not paste it into logs, analytics tools, or support tickets unless you are comfortable with that access.
Pre-signed URLs vs STS
Pre-signed URLs and STS solve different problems.
A pre-signed URL grants one temporary action, such as uploading one file to one key. It is great for simple browser upload and download flows.
AWS STS credentials are temporary credentials that can authorize multiple API actions based on a policy. They are more flexible, but they also give the client a broader credential surface to manage.
For most browser upload forms, start with pre-signed URLs. Reach for STS when the browser needs a session of broader storage operations, not one upload.
When to use this pattern
Use pre-signed URLs when you want:
- User avatars and profile images
- Document uploads
- Private media uploads
- Invoices, receipts, or reports
- Large files that should bypass your app server
- Temporary access to private objects
The main benefit is control without bandwidth bottlenecks. Your app decides who gets access. Object storage handles the bytes.
The short version
Browser uploads with pre-signed S3 URLs work by moving trust to your backend and file transfer to object storage.
Your backend signs a specific upload. The browser uses that URL to upload directly. The bucket CORS policy allows the browser request. Your database stores the object key after the upload succeeds.
That is the whole trick: no public bucket, no secret keys in the browser, and no app server sweating through every file upload.