Browser uploads with pre-signed URLs
The standard pattern for browser file uploads to S3-compatible storage:
- The browser asks your backend for a pre-signed URL scoped to one specific object.
- Your backend signs the URL using your access keys and returns just the URL.
- The browser PUTs the file directly to Filebase using the URL.
Your secret key never touches the browser. The user's network bandwidth doesn't pass through your servers. Your application server only handles a tiny signing request.
Backend — issue the pre-signed URL
import express from 'express';
import { S3Client, PutObjectCommand } 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!,
},
});
const app = express();
app.use(express.json());
app.post('/api/sign-upload', async (req, res) => {
// ALWAYS validate input here — see "Security" below
const { filename, contentType } = req.body;
if (!isAllowedContentType(contentType)) {
return res.status(400).json({ error: 'Unsupported file type' });
}
const userId = req.user.id; // however you authenticate
const key = `uploads/${userId}/${Date.now()}-${slugify(filename)}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: 'my-app-uploads',
Key: key,
ContentType: contentType,
}),
{ expiresIn: 600 }, // 10 minutes
);
res.json({ url, key });
});
Frontend — upload the file
async function uploadFile(file: File): Promise<{ key: string }> {
// 1. Get a pre-signed URL from the backend
const signRes = await fetch('/api/sign-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
if (!signRes.ok) throw new Error('Failed to sign upload');
const { url, key } = await signRes.json();
// 2. PUT the file directly to Filebase
const uploadRes = await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
if (!uploadRes.ok) throw new Error('Upload failed');
return { key };
}
Tracking progress
fetch in the browser doesn't expose upload progress. For a progress bar, use XMLHttpRequest:
function uploadWithProgress(
url: string,
file: File,
onProgress: (loaded: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(e.loaded, e.total);
});
xhr.onload = () => (xhr.status === 200 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(file);
});
}
CORS
The bucket must allow PUT requests from your application's origin:
{
"CORSRules": [
{
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedOrigins": ["https://app.example.com", "http://localhost:5173"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}
Apply with:
aws --endpoint https://s3.filebase.io s3api put-bucket-cors \
--bucket my-app-uploads \
--cors-configuration file://cors.json
See configure CORS for an SPA for the full walkthrough.
Multipart browser uploads
For files larger than ~100 MB, switch to a multipart browser upload pattern. The backend signs CreateMultipartUpload, each UploadPart, and CompleteMultipartUpload separately, and the browser uploads parts in parallel. The Uppy AWS S3 plugin implements this end-to-end.
Security
Pre-signed URLs are powerful — anyone holding the URL can perform the signed operation until expiry. Defenses:
- Validate
filenameandcontentTypeserver-side. Reject types you don't accept (.exe, executable scripts, etc.). - Constrain the upload by signing
ContentType(the browser must match) or, for stricter limits, generate a pre-signed POST withConditionslikecontent-length-rangeandstarts-with. - Authenticate the sign endpoint. Only authorized users should be able to request an upload URL.
- Use a short expiration (5–10 minutes for uploads).
- Use deterministic, namespaced keys (
uploads/<userId>/<timestamp>-<filename>). Reject keys with..or path-traversal patterns. - Scan or moderate uploaded content out-of-band if your application has a content policy.
Pre-signed POST (stricter constraints)
For tight control over what a user can upload — content-length range, content-type prefix, fixed metadata — use pre-signed POST instead of pre-signed PUT:
post = s3.generate_presigned_post(
Bucket='my-app-uploads',
Key=f'uploads/{user_id}/${{filename}}',
Conditions=[
['content-length-range', 0, 10 * 1024 * 1024], # max 10 MB
['starts-with', '$Content-Type', 'image/'], # images only
],
Fields={
'x-amz-meta-uploaded-by': str(user_id),
},
ExpiresIn=600,
)
The browser then submits a multipart form with the fields the response specifies plus the file. Filebase enforces the Conditions server-side.